commit 73b44d24e5b821fedb28f697ff2e5eb0eaeec2c1 Author: Fabio Date: Mon Oct 26 11:00:14 2026 +0100 Initial import s190js diff --git a/.env b/.env new file mode 100644 index 0000000..03fba06 --- /dev/null +++ b/.env @@ -0,0 +1,45 @@ +BASE_URL=https://prova.patachina.it +SERVER_PORT=4000 + +# cartella dove scrivere gli output HLS (relativa alla root del progetto) +HLS_BASE=./hls_output + +EMAIL=fabio@gmail.com +PASSWORD=master66 + +JWT_SECRET=123456789 +JWT_EXPIRES=1h + +# Dove si trova la cartella public (relativa alla root del progetto) +WEB_ROOT=public + +# Percorso relativo di index.json dentro public/ +INDEX_PATH=photos/index.json + +# true = restituisce path assoluti nei record +PATH_FULL=true + +# Logging +LOG_MODE=both # console | file | both +LOG_FILE=scan.log +LOG_DIR=public +LOG_VERBOSE=true + +# POLLING_TIME in secondi +# Funzionamento: +# - POLLING_TIME = 0 → polling disattivato (solo WebSocket) +# - POLLING_TIME > 0 → esegue incrementalSync() ogni N secondi + +# Perché esiste? +# - Serve come "rete di sicurezza" se il WebSocket cade, +# se la tab viene sospesa, o se qualche evento viene perso. +# - Google Photos, Dropbox e iCloud usano lo stesso approccio. +GALLERY_REFRESH_SECONDS=30 + +WS_PORT=4002 +WS_HOST=0.0.0.0 + +PHOTO_RETENTION_DAYS=30 + +MAPBOX_PUBLIC_TOKEN=pk.eyJ1IjoicGlwcG9jYWwiLCJhIjoiY2xka28zdWVjMHBkZzNwbnoyMmlxYjA3MyJ9.9D5EB0SWydvAQZr-SdI64Q +WORKER_STATUS_REFRESH_SECONDS=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbc4204 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# Node +node_modules/ + +# Python +__pycache__/ +*.pyc +.venv/ +.venv-clustering/ + +# SQLite +*.sqlite +*.sqlite-shm +*.sqlite-wal +api_v1/database.sqlite.pre-clear-* + +# Logs +*.log + +# Locks +*.lock + +# Media +public/photos/ +public/activities/ +public/cast/ +public/del_photos/ +public/del_activities/ + +del_photos/ +del_activities/ + +# Runtime +hls_output/ +out/ +out_fast/ +out_libx264/ +thumbs/ +var/ + +# Face runtime binaries +native/face-knn/bin/ + +# C/C++ +build/ +*.o +*.a +*.so + +# Backup +*.bak* +*.old +*.orig +*.before_* +*.before-* +*.ok + +# OS +.DS_Store +Thumbs.db + +# Backup e versioni temporanee sorgenti +*.js.* +*.css.* +*.html.* +*.sqlite.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..96e4ad0 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# Galleria con json-server e protetto con JWT S6J + +## Installazione + +clonare questa repo e installare tutte le dipendenze con `npm ci` + + +## Start/Stop servers + +| Description | Script | +| ------------------------- | -------------------- | +| Start server senza auth | `npm start-no-auth` | +| Start server con auth | `npm run start` | + +## Tools + +| Description | Script | +| ------------------------------ | ------------------- | +| Generate user hashed passwords | `npm run hash` | + + +[json-server api reference](https://github.com/typicode/json-server) + +## Come usarlo + +clonare e poi installare con + +``` +npm ci +``` + +nel file .env ci sono tutti i dati da modificare + +poi inserire in user.json user e password utilizzati per fare il login + +la password da inserire è criptata e viene generata con npm run hash + +il nome viene utilizzato come cartella da scansionare, si trova dentro photos + +es: +``` +name: Fabio + +public/photos +└── Fabio + └── original + └── 2017Irlanda19-29ago + ├── IMG_0092.JPG + ├── IMG_0099.JPG + ├── IMG_0100.JPG +``` +poi dentro Fabio genererà thumbs con tutti i thumbs + +- npm run start +- su IP:4000 ci sarà la galleria e andando su impostazioni si potrà fare lo scan di tutte le foto + +dopo aver fatto lo scan è possibile richiedere il json al server con tutte le informazioni anche senza autorizzazione + +basta farlo partire con npm run start-no-auth e le info si possono vedere con + +ip:4000/photos + +- npm start + + +--- + +#### FIT parse (utilizzo di python) + +Per avere il parse dei fit installare python, pipx e i seguenyi packages + +``` +sudo apt install pipx +pipx ensurepath +pipx install fitdecode +``` + +usa per sapere dove è installato python + +``` +pipx list +``` +in api_v1/scanner/parsers/parseFit.js va inserito il riferimento + +in questo caso + +``` +async function parseFit(absPath) { + const frames = await new Promise((resolve, reject) => { + execFile( + "/home/orangepi/.local/pipx/venvs/fitdecode/bin/python", + ["api_v1/scanner/parsers/parse_fit.py", absPath], + { maxBuffer: 1024 * 1024 * 50 }, // 50 MB +``` + +Inspired in this [post](https://www.techiediaries.com/fake-api-jwt-json-server/) by [Techiediaries](https://www.techiediaries.com/) + +#### Interrogazioni manuali + +per vedere il token + +``` +curl -s -X POST https://prova.patachina.it/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"fabio@gmail.com","password":"master66"}' +``` + +per salvare il token + +``` +TOKEN=$(curl -s -X POST https://prova.patachina.it/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"fabio@gmail.com","password":"master66"}' \ + | jq -r '.token') +``` + +per interrogare le foto + +``` +curl -s "https://prova.patachina.it/photos/" \ + -H "Authorization: Bearer $TOKEN" +``` + +per interrogare le attività + +``` +curl -s "https://prova.patachina.it/activities/" \ + -H "Authorization: Bearer $TOKEN" +``` diff --git a/api_v1/admin_secret.json b/api_v1/admin_secret.json new file mode 100644 index 0000000..0838ee0 --- /dev/null +++ b/api_v1/admin_secret.json @@ -0,0 +1,4 @@ +{ + "email": "admin@gmail.com", + "password": "master66" +} diff --git a/api_v1/config.js b/api_v1/config.js new file mode 100644 index 0000000..75df6e1 --- /dev/null +++ b/api_v1/config.js @@ -0,0 +1,36 @@ +require('dotenv').config(); + +module.exports = { + BASE_URL: process.env.BASE_URL, + + EMAIL: process.env.EMAIL, + PASSWORD: process.env.PASSWORD, + + SEND_PHOTOS: (process.env.SEND_PHOTOS || 'true').toLowerCase() === 'true', + + // Cartella public (root dei file statici) + WEB_ROOT: process.env.WEB_ROOT || 'public', + + // PATH_FULL ora è un boolean, non un path + PATH_FULL: (process.env.PATH_FULL || 'false').toLowerCase() === 'true', + + // Estensioni supportate per FOTO/VIDEO + SUPPORTED_EXTS: new Set([ + '.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif', + '.mp4', '.mov', '.m4v' + ]), + + // Estensioni supportate per ATTIVITÀ (FIT/GPX/TCX) + SUPPORTED_ACTIVITY_EXTS: new Set([ + '.fit', + '.gpx', + '.tcx' + ]), + + // 🔥 NUOVO: intervallo refresh gallery (in secondi) + GALLERY_REFRESH_SECONDS: Number(process.env.GALLERY_REFRESH_SECONDS || 30), + WORKER_STATUS_REFRESH_SECONDS: Number(process.env.WORKER_STATUS_REFRESH_SECONDS || 1), + + // 🔥 PORTA SERVER + PORT: parseInt(process.env.SERVER_PORT || "4000", 10) +}; diff --git a/api_v1/geo.js b/api_v1/geo.js new file mode 100644 index 0000000..23068e1 --- /dev/null +++ b/api_v1/geo.js @@ -0,0 +1,128 @@ +// api_v1/geo.js +const axios = require("axios"); + +async function loc(lng, lat) { +const primary = await place(lng, lat); + +if ( + primary && + primary.city && + primary.country && + primary.address +) { + return primary; +} + +const fallback = await placePhoton(lng, lat); + + if (!primary) return fallback; + + if (!primary.city && fallback?.city) { + primary.city = fallback.city; + } + + if (!primary.postcode && fallback?.postcode) { + primary.postcode = fallback.postcode; + } + + if (!primary.address && fallback?.address) { + primary.address = fallback.address; + } + + if (!primary.region && fallback?.region) { + primary.region = fallback.region; + } + + if (!primary.country_code && fallback?.country_code) { + primary.country_code = fallback.country_code; + } + + if (!primary.county && fallback?.county) { + primary.county = fallback.county; + } + + if (!primary.county_code && fallback?.county_code) { + primary.county_code = fallback.county_code; + } + + return primary; +} + +function normCountryCode(v) { + if (!v) return undefined; + const s = String(v).trim().toUpperCase(); + return s.length ? s : undefined; +} + +async function place(lng, lat) { + const apiKey = "6dc7fb95a3b246cfa0f3bcef5ce9ed9a"; + const url = `https://api.geoapify.com/v1/geocode/reverse?lat=${lat}&lon=${lng}&apiKey=${apiKey}`; + + try { + const r = await axios.get(url); + + if (r.status !== 200) return undefined; + if (!r.data.features || r.data.features.length === 0) return undefined; + + const k = r.data.features[0].properties; + + return { + continent: k?.timezone?.name?.split("/")?.[0] || undefined, + + // Country / Paese + country: k?.country || undefined, + country_code: normCountryCode(k?.country_code), + + // County / contea + county: k?.county || undefined, + county_code: k?.county_code || undefined, + + // Regione / stato amministrativo + region: k?.state || undefined, + + postcode: k?.postcode || undefined, + city: k?.city || k?.town || k?.village || undefined, + address: k?.address_line1 || undefined, + timezone: k?.timezone?.name || undefined, + time: k?.timezone?.offset_STD || undefined, + }; + } catch (err) { + return undefined; + } +} + +async function placePhoton(lng, lat) { + try { + const url = `https://photon.patachina.it/reverse?lon=${lng}&lat=${lat}`; + const r = await axios.get(url); + + if (!r.data || !r.data.features || r.data.features.length === 0) { + return undefined; + } + + const p = r.data.features[0].properties; + + return { + continent: undefined, + + // Country / Paese + country: p.country || undefined, + country_code: normCountryCode(p.countrycode || p.country_code), + + // County / contea, se disponibile + county: p.county || undefined, + county_code: p.county_code || undefined, + + region: p.state || undefined, + postcode: p.postcode || undefined, + city: p.city || p.town || p.village || undefined, + address: p.street ? `${p.street} ${p.housenumber || ""}`.trim() : undefined, + timezone: undefined, + time: undefined, + }; + } catch (err) { + return undefined; + } +} + +module.exports = loc; diff --git a/api_v1/scanner/activityIngestQueue.js b/api_v1/scanner/activityIngestQueue.js new file mode 100644 index 0000000..8ed93eb --- /dev/null +++ b/api_v1/scanner/activityIngestQueue.js @@ -0,0 +1,527 @@ +'use strict'; + +const crypto = require('crypto'); + +const { + withSqliteBusyRetry, +} = require('../../db/sqliteRetry'); + +const TABLE = 'activity_ingest_jobs'; + +function makeJobId(user, cartella, relPath) { + return crypto + .createHash('sha256') + .update(JSON.stringify([user, cartella, relPath])) + .digest('hex'); +} + +async function ensureActivityIngestQueue(db) { + await withSqliteBusyRetry( + 'activity queue ensure', + async () => { + if (!(await db.schema.hasTable(TABLE))) { + await db.schema.createTable(TABLE, (t) => { + t.string('id').primary(); + t.string('user').notNullable(); + t.string('cartella').notNullable(); + t.string('rel_path').notNullable(); + + t.integer('source_mtime_ms').notNullable(); + t.integer('source_size_bytes').notNullable(); + + t.string('status') + .notNullable() + .defaultTo('pending'); + + t.integer('attempts') + .notNullable() + .defaultTo(0); + + t.integer('rescan_requested') + .notNullable() + .defaultTo(0); + + t.text('last_error').nullable(); + + t.datetime('started_at').nullable(); + t.datetime('completed_at').nullable(); + + t.datetime('created_at') + .notNullable() + .defaultTo(db.fn.now()); + + t.datetime('updated_at') + .notNullable() + .defaultTo(db.fn.now()); + + t.index( + ['status', 'created_at'], + 'idx_activity_ingest_status_created' + ); + + t.index( + ['user', 'cartella'], + 'idx_activity_ingest_folder' + ); + }); + } + + if ( + !(await db.schema.hasColumn( + TABLE, + 'rescan_requested' + )) + ) { + await db.schema.alterTable(TABLE, (t) => { + t.integer('rescan_requested') + .notNullable() + .defaultTo(0); + }); + } + } + ); +} + +async function enqueueActivityIngestJob(db, input) { + const { + user, + cartella, + relPath, + mtimeMs, + sizeBytes, + } = input; + + if ( + typeof user !== 'string' || !user || + typeof cartella !== 'string' || !cartella || + typeof relPath !== 'string' || !relPath || + !Number.isFinite(mtimeMs) || mtimeMs < 0 || + !Number.isFinite(sizeBytes) || sizeBytes < 0 + ) { + throw new TypeError( + 'Dati non validi per accodare activity' + ); + } + + await ensureActivityIngestQueue(db); + + const id = makeJobId(user, cartella, relPath); + const mtime = Math.trunc(mtimeMs); + const size = Math.trunc(sizeBytes); + + return withSqliteBusyRetry( + 'activity queue enqueue', + () => db.transaction(async (trx) => { + const existing = await trx(TABLE) + .where({ id }) + .first(); + + if (existing) { + const unchanged = + Number(existing.source_mtime_ms) === mtime && + Number(existing.source_size_bytes) === size; + + if (unchanged) { + return { + id, + status: existing.status, + unchanged: true, + }; + } + + if (existing.status === 'processing') { + await trx(TABLE) + .where({ + id, + status: 'processing', + }) + .update({ + source_mtime_ms: mtime, + source_size_bytes: size, + rescan_requested: 1, + updated_at: trx.fn.now(), + }); + + return { + id, + status: 'processing', + changedWhileProcessing: true, + }; + } + + await trx(TABLE) + .where({ id }) + .update({ + source_mtime_ms: mtime, + source_size_bytes: size, + status: 'pending', + attempts: 0, + rescan_requested: 0, + last_error: null, + started_at: null, + completed_at: null, + updated_at: trx.fn.now(), + }); + + return { + id, + status: 'pending', + updated: true, + }; + } + + await trx(TABLE).insert({ + id, + user, + cartella, + rel_path: relPath, + source_mtime_ms: mtime, + source_size_bytes: size, + status: 'pending', + attempts: 0, + rescan_requested: 0, + created_at: trx.fn.now(), + updated_at: trx.fn.now(), + }); + + return { + id, + status: 'pending', + inserted: true, + }; + }) + ); +} + + +async function enqueueActivityIngestJobs(db, inputs) { + if (!Array.isArray(inputs)) { + throw new TypeError('Il batch deve essere un array'); + } + if (inputs.length === 0) return []; + + for (const input of inputs) { + const { user, cartella, relPath, mtimeMs, sizeBytes } = input || {}; + if ( + typeof user !== 'string' || !user || + typeof cartella !== 'string' || !cartella || + typeof relPath !== 'string' || !relPath || + !Number.isFinite(mtimeMs) || mtimeMs < 0 || + !Number.isFinite(sizeBytes) || sizeBytes < 0 + ) { + throw new TypeError('Dati non validi nel batch activity'); + } + } + + await ensureActivityIngestQueue(db); + + return withSqliteBusyRetry( + 'activity queue enqueue batch', + () => db.transaction(async (trx) => { + const results = []; + + for (const input of inputs) { + const { user, cartella, relPath, mtimeMs, sizeBytes } = input; + const id = makeJobId(user, cartella, relPath); + const mtime = Math.trunc(mtimeMs); + const size = Math.trunc(sizeBytes); + + const existing = await trx(TABLE).where({ id }).first(); + + if (existing) { + const unchanged = + Number(existing.source_mtime_ms) === mtime && + Number(existing.source_size_bytes) === size; + + if (unchanged) { + results.push({ + id, status: existing.status, unchanged: true, + }); + continue; + } + + if (existing.status === 'processing') { + await trx(TABLE) + .where({ id, status: 'processing' }) + .update({ + source_mtime_ms: mtime, + source_size_bytes: size, + rescan_requested: 1, + updated_at: trx.fn.now(), + }); + + results.push({ + id, status: 'processing', changedWhileProcessing: true, + }); + continue; + } + + await trx(TABLE).where({ id }).update({ + source_mtime_ms: mtime, + source_size_bytes: size, + status: 'pending', + attempts: 0, + rescan_requested: 0, + last_error: null, + started_at: null, + completed_at: null, + updated_at: trx.fn.now(), + }); + + results.push({ id, status: 'pending', updated: true }); + continue; + } + + await trx(TABLE).insert({ + id, + user, + cartella, + rel_path: relPath, + source_mtime_ms: mtime, + source_size_bytes: size, + status: 'pending', + attempts: 0, + rescan_requested: 0, + created_at: trx.fn.now(), + updated_at: trx.fn.now(), + }); + + results.push({ id, status: 'pending', inserted: true }); + } + + return results; + }) + ); +} + +async function claimNextActivityIngestJob(db) { + await ensureActivityIngestQueue(db); + + return withSqliteBusyRetry( + 'activity queue claim', + () => db.transaction(async (trx) => { + const job = await trx(TABLE) + .where({ status: 'pending' }) + .orderBy('created_at', 'asc') + .first(); + + if (!job) return null; + + const changed = await trx(TABLE) + .where({ + id: job.id, + status: 'pending', + }) + .update({ + status: 'processing', + attempts: trx.raw('attempts + 1'), + rescan_requested: 0, + started_at: trx.fn.now(), + completed_at: null, + last_error: null, + updated_at: trx.fn.now(), + }); + + if (!changed) return null; + + return { + ...job, + status: 'processing', + attempts: Number(job.attempts || 0) + 1, + rescan_requested: 0, + }; + }) + ); +} + +async function completeActivityIngestJob(db, id) { + return withSqliteBusyRetry( + 'activity queue complete', + () => db.transaction(async (trx) => { + const job = await trx(TABLE) + .where({ + id, + status: 'processing', + }) + .first(); + + if (!job) { + return { updated: false }; + } + + if (Number(job.rescan_requested) === 1) { + await trx(TABLE) + .where({ + id, + status: 'processing', + }) + .update({ + status: 'pending', + rescan_requested: 0, + started_at: null, + completed_at: null, + last_error: null, + updated_at: trx.fn.now(), + }); + + return { + updated: true, + status: 'pending', + requeued: true, + }; + } + + await trx(TABLE) + .where({ + id, + status: 'processing', + }) + .update({ + status: 'done', + completed_at: trx.fn.now(), + updated_at: trx.fn.now(), + last_error: null, + }); + + return { + updated: true, + status: 'done', + requeued: false, + }; + }) + ); +} + +async function failActivityIngestJob( + db, + id, + error +) { + const message = String( + error?.stack || + error?.message || + error + ).slice(0, 12000); + + return withSqliteBusyRetry( + 'activity queue fail', + () => db.transaction(async (trx) => { + const job = await trx(TABLE) + .where({ + id, + status: 'processing', + }) + .first(); + + if (!job) { + return { updated: false }; + } + + if (Number(job.rescan_requested) === 1) { + await trx(TABLE) + .where({ + id, + status: 'processing', + }) + .update({ + status: 'pending', + rescan_requested: 0, + started_at: null, + completed_at: null, + last_error: message, + updated_at: trx.fn.now(), + }); + + return { + updated: true, + status: 'pending', + requeued: true, + }; + } + + await trx(TABLE) + .where({ + id, + status: 'processing', + }) + .update({ + status: 'error', + last_error: message, + completed_at: trx.fn.now(), + updated_at: trx.fn.now(), + }); + + return { + updated: true, + status: 'error', + requeued: false, + }; + }) + ); +} + +async function resetStaleActivityIngestJobs( + db, + staleMinutes = 30 +) { + await ensureActivityIngestQueue(db); + + const cutoff = new Date( + Date.now() - staleMinutes * 60 * 1000 + ) + .toISOString() + .replace('T', ' ') + .replace(/\.\d{3}Z$/, ''); + + return withSqliteBusyRetry( + 'activity queue stale reset', + () => db(TABLE) + .where({ status: 'processing' }) + .whereNotNull('started_at') + .where('started_at', '<', cutoff) + .update({ + status: 'pending', + rescan_requested: 0, + started_at: null, + completed_at: null, + last_error: + 'Recuperato dopo interruzione del worker', + updated_at: db.fn.now(), + }) + ); +} + +async function getActivityIngestQueueCounts(db) { + await ensureActivityIngestQueue(db); + + const rows = await db(TABLE) + .select('status') + .count({ total: '*' }) + .groupBy('status'); + + const counts = { + pending: 0, + processing: 0, + done: 0, + error: 0, + }; + + for (const row of rows) { + if (Object.hasOwn(counts, row.status)) { + counts[row.status] = Number(row.total); + } + } + + return counts; +} + +module.exports = { + ensureActivityIngestQueue, + enqueueActivityIngestJob, + enqueueActivityIngestJobs, + claimNextActivityIngestJob, + completeActivityIngestJob, + failActivityIngestJob, + resetStaleActivityIngestJobs, + getActivityIngestQueueCounts, +}; diff --git a/api_v1/scanner/activityIngestWorker.js b/api_v1/scanner/activityIngestWorker.js new file mode 100644 index 0000000..5a7de33 --- /dev/null +++ b/api_v1/scanner/activityIngestWorker.js @@ -0,0 +1,420 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs/promises'); + +const { + WEB_ROOT, + SUPPORTED_ACTIVITY_EXTS, + SEND_PHOTOS, + BASE_URL, +} = require('../config'); + +const scanActivitySingle = + require('./scanActivitySingle'); + +const postWithAuth = + require('./postWithAuth'); + +const { sha256 } = require('./utils'); + +const { + enqueueServerEvent, +} = require('../../db/serverEventOutbox'); + +const { + ensureActivityIngestQueue, + claimNextActivityIngestJob, + enqueueActivityIngestJob, + completeActivityIngestJob, + failActivityIngestJob, + resetStaleActivityIngestJobs, +} = require('./activityIngestQueue'); + +function sameVersion(stat, job) { + return ( + Math.trunc(stat.mtimeMs) === + Math.trunc(Number(job.source_mtime_ms)) && + stat.size === + Number(job.source_size_bytes) + ); +} + +function isInside(parent, child) { + const relative = path.relative(parent, child); + + return ( + relative === '' || + ( + !relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative) + ) + ); +} + +async function resolveActivityJobFile(job) { + for (const value of [ + job.user, + job.cartella, + job.rel_path, + ]) { + if ( + typeof value !== 'string' || + !value.trim() + ) { + throw new Error( + 'Percorso activity job non valido' + ); + } + } + + if ( + path.isAbsolute(job.rel_path) || + job.rel_path.includes('\\') || + job.rel_path + .split('/') + .some(part => + !part || + part === '.' || + part === '..' + ) + ) { + throw new Error( + 'Percorso relativo activity non sicuro' + ); + } + + if ( + job.user === '.' || + job.user === '..' || + job.cartella === '.' || + job.cartella === '..' || + job.user.includes('/') || + job.user.includes('\\') || + job.cartella.includes('/') || + job.cartella.includes('\\') + ) { + throw new Error( + 'Utente o cartella activity non validi' + ); + } + + const activitiesRoot = path.resolve( + __dirname, + '..', + '..', + WEB_ROOT, + 'activities' + ); + + const originalRoot = path.resolve( + activitiesRoot, + job.user, + 'original', + job.cartella + ); + + const absPath = path.resolve( + originalRoot, + ...job.rel_path.split('/') + ); + + if (!isInside(originalRoot, absPath)) { + throw new Error( + 'Activity file esce dalla cartella prevista' + ); + } + + const realRoot = await fs.realpath( + originalRoot + ); + + const realFile = await fs.realpath( + absPath + ); + + if (!isInside(realRoot, realFile)) { + throw new Error( + 'Activity file risolve fuori dalla cartella prevista' + ); + } + + const ext = path + .extname(absPath) + .toLowerCase(); + + if (!SUPPORTED_ACTIVITY_EXTS.has(ext)) { + throw new Error( + `Estensione activity non supportata: ${ext}` + ); + } + + const stat = await fs.stat(realFile); + + if (!stat.isFile()) { + throw new Error( + 'Activity path non e un file regolare' + ); + } + + return { + id: sha256( + `${job.user}/${job.cartella}/${job.rel_path}` + ), + user: job.user, + cartella: job.cartella, + file_name: path.basename(absPath), + name: path.basename(absPath), + relPath: job.rel_path, + absPath: realFile, + ext, + stat, + path: + `/activities/${job.user}/original/` + + `${job.cartella}/${job.rel_path}`, + }; +} + +async function runOneJob(db) { + await ensureActivityIngestQueue(db); + + const job = + await claimNextActivityIngestJob(db); + + if (!job) { + return { processed: false }; + } + + console.log( + `[ActivityIngest] Avvio job ${job.id}: ` + + `${job.rel_path}` + ); + + try { + const file = + await resolveActivityJobFile(job); + + /* + * Se il file e' gia cambiato prima di iniziare + * l'elaborazione, aggiorna la versione in queue. + */ + if (!sameVersion(file.stat, job)) { + await enqueueActivityIngestJob(db, { + user: job.user, + cartella: job.cartella, + relPath: job.rel_path, + mtimeMs: file.stat.mtimeMs, + sizeBytes: file.stat.size, + }); + + const result = + await completeActivityIngestJob( + db, + job.id + ); + + return { + processed: true, + status: result.status, + requeued: Boolean(result.requeued), + }; + } + + // Memorizza lo stato precedente per distinguere inserimenti e aggiornamenti. + const previousActivity = await db('activities') + .select('id', 'deleted_at') + .where({ id: file.id }) + .first(); + + const newActivities = []; + + const scannedActivity = + await scanActivitySingle( + db, + job.user, + job.cartella, + file, + newActivities + ); + + /* + * Ricontrolla il file dopo il parsing: + * se e' cambiato mentre veniva elaborato, + * richiede una seconda scansione. + */ + /* + * Sincronizzazione verso BASE_URL. + * + * La semantica resta quella del vecchio scanActivity: + * un errore remoto viene registrato ma NON rende fallito + * l'ingest locale. + */ + if ( + scannedActivity && + SEND_PHOTOS && + BASE_URL + ) { + try { + scannedActivity.user = job.user; + + await postWithAuth( + `${BASE_URL}/activities`, + scannedActivity + ); + + console.log( + `[ActivityIngest] Activity ${scannedActivity.id} ` + + `inviata al server remoto` + ); + } catch (remoteError) { + console.error( + `[ActivityIngest] Invio remoto fallito ` + + `${scannedActivity.id}:`, + remoteError.message + ); + } + } + + const statAfter = + await fs.stat(file.absPath); + + if ( + Math.trunc(statAfter.mtimeMs) !== + Math.trunc(file.stat.mtimeMs) || + statAfter.size !== file.stat.size + ) { + await enqueueActivityIngestJob(db, { + user: job.user, + cartella: job.cartella, + relPath: job.rel_path, + mtimeMs: statAfter.mtimeMs, + sizeBytes: statAfter.size, + }); + } + + const result = + await completeActivityIngestJob( + db, + job.id + ); + + console.log( + `[ActivityIngest] Job ${job.id} ` + + `completato; stato: ${result.status}` + ); + + // Notifica la galleria solo dopo il completamento effettivo dell'ingest. + if ( + scannedActivity && + scannedActivity.id && + !result.requeued && + result.status === 'done' + ) { + await enqueueServerEvent(db, { + channel: 'activity', + user: job.user, + payload: { + type: + previousActivity && !previousActivity.deleted_at + ? 'updated' + : 'added', + id: scannedActivity.id, + entity: 'activity', + mode: 'activities', + }, + }); + + console.log( + `[ActivityIngest] Evento gallery accodato ` + + `activity=${scannedActivity.id}` + ); + } + + return { + processed: true, + status: result.status, + requeued: Boolean(result.requeued), + activityId: + scannedActivity?.id || file.id, + }; + } catch (error) { + console.error( + `[ActivityIngest] Errore job ${job.id}:`, + error.message + ); + + try { + await failActivityIngestJob( + db, + job.id, + error + ); + } catch (queueError) { + console.error( + '[ActivityIngest] Errore aggiornando queue:', + queueError.message + ); + } + + return { + processed: true, + error: error.message, + }; + } +} + +async function runWorker( + db, + options = {} +) { + const pollMs = Math.max( + 250, + Number(options.pollMs) || 2000 + ); + + const staleMinutes = Math.max( + 1, + Number(options.staleMinutes) || 30 + ); + + await ensureActivityIngestQueue(db); + + await resetStaleActivityIngestJobs( + db, + staleMinutes + ); + + console.log( + `[ActivityIngest] Worker avviato; ` + + `polling ${pollMs} ms` + ); + + while (true) { + try { + const result = await runOneJob(db); + + if (!result.processed) { + await new Promise( + resolve => setTimeout(resolve, pollMs) + ); + } + } catch (error) { + console.error( + '[ActivityIngest] Errore worker:', + error + ); + + await new Promise( + resolve => setTimeout(resolve, pollMs) + ); + } + } +} + +module.exports = { + runOneJob, + runWorker, + resolveActivityJobFile, + sameVersion, +}; diff --git a/api_v1/scanner/activityOrphanCleanup.js b/api_v1/scanner/activityOrphanCleanup.js new file mode 100644 index 0000000..8addbf9 --- /dev/null +++ b/api_v1/scanner/activityOrphanCleanup.js @@ -0,0 +1,57 @@ +const RETENTION_DAYS = 30; + +module.exports = function createActivityOrphanCleanup(db) { + async function deleteFromDB(id, user) { + const now = new Date(); + const nowIso = now.toISOString(); + + const rec = await db('activities') + .where({ id, user }) + .first(); + + if (!rec) return false; + + // Primo rilevamento: soft delete. + if (!rec.deleted_at) { + await db('activities') + .where({ id, user }) + .update({ + deleted_at: nowIso, + updated_at: nowIso, + }); + + console.log(`🟡 Soft delete attività orfana: ${id}`); + return true; + } + + // Hard delete solo dopo il periodo di retention. + const deletedAt = new Date(rec.deleted_at); + const ageMs = now.getTime() - deletedAt.getTime(); + const retentionMs = RETENTION_DAYS * 24 * 60 * 60 * 1000; + + if (!Number.isFinite(deletedAt.getTime()) || ageMs <= retentionMs) { + console.log(`🟡 Attività in retention: ${id}`); + return true; + } + + await db.transaction(async (trx) => { + await trx('activities') + .where({ id, user }) + .del(); + + await trx('deleted_hard_activities') + .insert({ + id, + user, + deleted_at: nowIso, + }) + .onConflict('id') + .merge(); + }); + + console.log(`🔴 Hard delete attività orfana: ${id}`); + return true; + } + + return { deleteFromDB }; +}; diff --git a/api_v1/scanner/activityScanProgress.js b/api_v1/scanner/activityScanProgress.js new file mode 100644 index 0000000..45c38d7 --- /dev/null +++ b/api_v1/scanner/activityScanProgress.js @@ -0,0 +1,92 @@ +'use strict'; + +const activityIngestQueue = require('./activityIngestQueue'); + +let epoch = null; + +async function beginGlobalActivityScan(db) { + if (!epoch || !epoch.active) { + const created = { + active: true, + baselineDone: 0, + enumerationsInProgress: 0, + initPromise: null, + }; + + epoch = created; + created.initPromise = activityIngestQueue + .getActivityIngestQueueCounts(db) + .then(counts => { + created.baselineDone = Number(counts.done || 0); + }) + .catch(err => { + if (epoch === created) created.active = false; + throw err; + }); + } + + const current = epoch; + current.enumerationsInProgress++; + + try { + await current.initPromise; + } catch (err) { + current.enumerationsInProgress = Math.max( + 0, current.enumerationsInProgress - 1 + ); + throw err; + } +} + +function finishGlobalActivityScan() { + if (epoch) { + epoch.enumerationsInProgress = Math.max( + 0, epoch.enumerationsInProgress - 1 + ); + } +} + +async function getGlobalActivityScanStatus(db) { + const counts = await activityIngestQueue.getActivityIngestQueueCounts(db); + const pending = Number(counts.pending || 0); + const processing = Number(counts.processing || 0); + const done = Number(counts.done || 0); + const errors = Number(counts.error || 0); + const remaining = pending + processing; + const totalCurrent = pending + processing + done + errors; + + let current; + let total; + let percent; + let baselineDone = done; + + if (epoch && epoch.active) { + baselineDone = epoch.baselineDone; + total = Math.max(0, totalCurrent - baselineDone); + current = Math.max(0, (done - baselineDone) + errors); + percent = total <= 0 + ? 100 + : Math.max(0, Math.min(100, Math.round(current / total * 100))); + + // Chiude il ciclo quando tutte le scansioni hanno terminato + // l'accodamento e non restano job pending o processing. + if (epoch.enumerationsInProgress === 0 && remaining === 0) { + epoch.active = false; + } + } else { + total = totalCurrent; + current = totalCurrent; + percent = 100; + } + + return { + pending, processing, done, errors, remaining, + current, total, percent, baselineDone, + }; +} + +module.exports = { + beginGlobalActivityScan, + finishGlobalActivityScan, + getGlobalActivityScanStatus, +}; diff --git a/api_v1/scanner/debugVideoDates.js b/api_v1/scanner/debugVideoDates.js new file mode 100644 index 0000000..2119aa9 --- /dev/null +++ b/api_v1/scanner/debugVideoDates.js @@ -0,0 +1,64 @@ +// api_v1/scanner/debugVideoDates.js +const path = require('path'); +const { probeVideo } = require('./video'); + +async function debugVideo(absPath) { + console.log("🎥 File:", absPath); + + const info = await probeVideo(absPath); + + console.log("\n=== RAW probeVideo(info) ===\n"); + console.dir(info, { depth: 6 }); + + // Estrazione campi data come nello scanner + const formatTags = info.format?.tags || {}; + const stream0Tags = info.streams?.[0]?.tags || {}; + const stream1Tags = info.streams?.[1]?.tags || {}; + + const creationFormat = formatTags.creation_time || null; + const creationStream0 = stream0Tags.creation_time || null; + const creationStream1 = stream1Tags.creation_time || null; + + console.log("\n=== DATE CANDIDATE ==="); + console.log("format.tags.creation_time :", creationFormat); + console.log("streams[0].tags.creation_time:", creationStream0); + console.log("streams[1].tags.creation_time:", creationStream1); + + // Simulazione logica attuale dello scanner (ma esplicita) + let videoDate = + creationFormat || + creationStream0 || + creationStream1 || + null; + + console.log("\nScelta videoDate (prima di qualsiasi conversione):", videoDate); + + if (videoDate) { + // Variante 1: SENZA conversione (quella che ti ho suggerito) + const takenAtRaw = videoDate; + + // Variante 2: CON conversione (quella che ti sballa il giorno) + const takenAtIso = new Date(videoDate).toISOString(); + + console.log("\n=== CONFRONTO ==="); + console.log("takenAtRaw (usato così com'è) :", takenAtRaw); + console.log("takenAtIso (new Date().toISOString):", takenAtIso); + } else { + console.log("\n⚠ Nessuna data trovata nei tag video."); + } +} + +async function main() { + const arg = process.argv[2]; + if (!arg) { + console.error("Uso: node debugVideoDates.js /percorso/al/video.mp4"); + process.exit(1); + } + + const absPath = path.resolve(arg); + await debugVideo(absPath); +} + +main().catch(err => { + console.error("❌ Errore debugVideoDates:", err); +}); diff --git a/api_v1/scanner/deleteFolder.js b/api_v1/scanner/deleteFolder.js new file mode 100644 index 0000000..6c77ce5 --- /dev/null +++ b/api_v1/scanner/deleteFolder.js @@ -0,0 +1,73 @@ +// api_v1/scanner/deleteFolder.js +const path = require('path'); +const fsp = require('fs/promises'); +const { WEB_ROOT } = require('../config'); + +module.exports = function createDeleteFolderFunctions(db, RETENTION_DAYS = 30) { + + const createCleanupFunctions = require('./orphanCleanup'); + const { deleteFromDB, deleteThumbsById } = createCleanupFunctions(db, RETENTION_DAYS); + + /** + * Cancella una cartella per un utente: + * - soft delete immediato di tutte le foto + * - hard delete se retention scaduta + * - registra hard delete in deleted_hard + * - elimina thumbs solo per hard delete + */ + async function deleteFolderForUser(userName, folderName) { + + // 1) Recupera tutti gli ID nel DB + const rows = await db('photos') + .where({ user: userName, cartella: folderName }) + .select('id'); + + let softCount = 0; + let hardCount = 0; + + // 2) Soft delete / Hard delete per ogni foto + for (const r of rows) { + const id = r.id; + + const result = await deleteFromDB(id, userName); + + if (result === true) { + // deleteFromDB decide se è soft o hard + // possiamo verificare se la foto esiste ancora nel DB + const stillExists = await db("photos") + .where({ id, user: userName }) + .first(); + + if (stillExists) { + softCount++; + } else { + hardCount++; + } + } + } + + // 3) Cancella la cartella thumbs residua (solo se hard delete totale) + const thumbsDir = path.join( + WEB_ROOT, + userName, + "thumbs", + folderName + ); + + try { + await fsp.rm(thumbsDir, { recursive: true, force: true }); + console.log(`🧹 Rimossa cartella thumbs residua: ${thumbsDir}`); + } catch (err) { + console.log(`⚠️ Errore rimozione thumbs dir: ${thumbsDir}`, err); + } + + return { + totalPhotos: rows.length, + softDeleted: softCount, + hardDeleted: hardCount, + removedThumbsDir: thumbsDir + }; + } + + return { deleteFolderForUser }; +}; diff --git a/api_v1/scanner/deleteWithAuth.js b/api_v1/scanner/deleteWithAuth.js new file mode 100644 index 0000000..d24ff2d --- /dev/null +++ b/api_v1/scanner/deleteWithAuth.js @@ -0,0 +1,19 @@ +// scanner/deleteWithAuth.js +const { API_KEY } = require('../config'); + +module.exports = async function deleteWithAuth(url) { + // import dinamico compatibile con CommonJS + const fetch = (await import('node-fetch')).default; + + const res = await fetch(url, { + method: 'DELETE', + headers: { + 'x-api-key': API_KEY, + 'Content-Type': 'application/json' + } + }); + + if (!res.ok) { + throw new Error(`DELETE failed: ${res.status}`); + } +}; diff --git a/api_v1/scanner/elevation.js b/api_v1/scanner/elevation.js new file mode 100644 index 0000000..b3ac772 --- /dev/null +++ b/api_v1/scanner/elevation.js @@ -0,0 +1,81 @@ +// api_v1/scanner/elevation.js + + +// --------------------------------------------------------- +// Provider 1: OpenElevation +// --------------------------------------------------------- +async function tryOpenElevation(lat, lon) { + try { + const url = `https://api.open-elevation.com/api/v1/lookup?locations=${lat},${lon}`; + const res = await fetch(url, { timeout: 5000 }); + + if (!res.ok) { + console.log("OpenElevation status:", res.status); + return null; + } + + const text = await res.text(); + + // Se non è JSON → errore HTML → fallback + if (!text.startsWith('{')) { + console.log("OpenElevation non-JSON:", text.slice(0, 60)); + return null; + } + + const data = JSON.parse(text); + return data?.results?.[0]?.elevation ?? null; + + } catch (err) { + console.log("Errore OpenElevation:", err.message); + return null; + } +} + +// --------------------------------------------------------- +// Provider 2: OpenTopoData (fallback) +// --------------------------------------------------------- +async function tryOpenTopoData(lat, lon) { + try { + const url = `https://api.opentopodata.org/v1/eudem25m?locations=${lat},${lon}`; + const res = await fetch(url, { timeout: 5000 }); + + if (!res.ok) { + console.log("OpenTopoData status:", res.status); + return null; + } + + const text = await res.text(); + + if (!text.startsWith('{')) { + console.log("OpenTopoData non-JSON:", text.slice(0, 60)); + return null; + } + + const data = JSON.parse(text); + return data?.results?.[0]?.elevation ?? null; + + } catch (err) { + console.log("Errore OpenTopoData:", err.message); + return null; + } +} + +// --------------------------------------------------------- +// Funzione principale con fallback +// --------------------------------------------------------- +async function getElevation(lat, lon) { + // 1️⃣ Prova OpenElevation + const elev1 = await tryOpenElevation(lat, lon); + if (elev1 !== null) return elev1; + + console.log("⚠️ OpenElevation fallito → uso OpenTopoData"); + + // 2️⃣ Prova OpenTopoData + const elev2 = await tryOpenTopoData(lat, lon); + if (elev2 !== null) return elev2; + + console.log("❌ Nessun provider di elevazione disponibile"); + return null; +} + +module.exports = getElevation; diff --git a/api_v1/scanner/faceScanWorker.js b/api_v1/scanner/faceScanWorker.js new file mode 100644 index 0000000..c321102 --- /dev/null +++ b/api_v1/scanner/faceScanWorker.js @@ -0,0 +1,492 @@ +const db = require('../../db/knex'); + +const { + scanPhotoFacesById, +} = require('./faceScanner'); + +function serializeError(error) { + const value = + error?.stack + || error?.message + || String(error); + + return String(value).slice(0, 16000); +} + +async function claimNextPendingJob() { + return db.transaction(async (trx) => { + const job = await trx('face_scan_jobs') + .where({ status: 'pending' }) + .orderBy('created_at', 'asc') + .orderBy('photo_id', 'asc') + .first(); + + if (!job) { + return null; + } + + const updated = await trx('face_scan_jobs') + .where({ + photo_id: job.photo_id, + status: 'pending', + }) + .update({ + status: 'processing', + attempts: trx.raw('attempts + 1'), + faces_detected: null, + execution_ms: null, + applied_rotation: null, + last_error: null, + started_at: trx.fn.now(), + completed_at: null, + updated_at: trx.fn.now(), + }); + + if (updated !== 1) { + return null; + } + + return trx('face_scan_jobs') + .where({ photo_id: job.photo_id }) + .first(); + }); +} + +async function completeJob(photoId, result) { + return db.transaction(async (trx) => { + const job = await trx('face_scan_jobs') + .where({ + photo_id: photoId, + status: 'processing', + }) + .first(); + + if (!job) { + throw new Error( + `Job processing non trovato: photo_id=${photoId}`, + ); + } + + if (Number(job.rescan_requested) === 1) { + const updated = await trx('face_scan_jobs') + .where({ + photo_id: photoId, + status: 'processing', + }) + .update({ + status: 'pending', + rescan_requested: 0, + faces_detected: null, + execution_ms: null, + applied_rotation: null, + last_error: null, + started_at: null, + completed_at: null, + updated_at: trx.fn.now(), + }); + + if (updated !== 1) { + throw new Error( + `Impossibile riaccodare il job photo_id=${photoId}`, + ); + } + + return 'pending'; + } + + const updated = await trx('face_scan_jobs') + .where({ + photo_id: photoId, + status: 'processing', + }) + .update({ + status: 'done', + rescan_requested: 0, + faces_detected: result.faces_detected, + execution_ms: result.execution_ms, + applied_rotation: result.applied_rotation, + last_error: null, + completed_at: trx.fn.now(), + updated_at: trx.fn.now(), + }); + + if (updated !== 1) { + throw new Error( + `Impossibile completare il job photo_id=${photoId}`, + ); + } + + return 'done'; + }); +} + +async function failJob(photoId, error) { + return db.transaction(async (trx) => { + const job = await trx('face_scan_jobs') + .where({ + photo_id: photoId, + status: 'processing', + }) + .first(); + + if (!job) { + console.error( + '[faceScanWorker] job processing non trovato:', + photoId, + ); + + return 'missing'; + } + + if (Number(job.rescan_requested) === 1) { + const updated = await trx('face_scan_jobs') + .where({ + photo_id: photoId, + status: 'processing', + }) + .update({ + status: 'pending', + rescan_requested: 0, + faces_detected: null, + execution_ms: null, + applied_rotation: null, + last_error: null, + started_at: null, + completed_at: null, + updated_at: trx.fn.now(), + }); + + if (updated !== 1) { + throw new Error( + `Impossibile riaccodare il job photo_id=${photoId}`, + ); + } + + return 'pending'; + } + + const updated = await trx('face_scan_jobs') + .where({ + photo_id: photoId, + status: 'processing', + }) + .update({ + status: 'error', + rescan_requested: 0, + last_error: serializeError(error), + completed_at: trx.fn.now(), + updated_at: trx.fn.now(), + }); + + if (updated !== 1) { + console.error( + '[faceScanWorker] impossibile registrare errore:', + photoId, + ); + + return 'missing'; + } + + return 'error'; + }); +} + +async function completeJobWithRetry(photoId, result) { + const delays = [0, 250, 500, 1000, 2000]; + + for (let attempt = 0; attempt < delays.length; attempt += 1) { + if (delays[attempt] > 0) { + await new Promise((resolve) => setTimeout(resolve, delays[attempt])); + } + + try { + return await completeJob(photoId, result); + } catch (error) { + const message = String(error?.message || error); + const code = String(error?.code || ''); + const isBusy = + code === 'SQLITE_BUSY' + || code === 'SQLITE_LOCKED' + || message.includes('database is locked') + || message.includes('SQLITE_BUSY') + || message.includes('SQLITE_LOCKED'); + + if (!isBusy || attempt === delays.length - 1) { + throw error; + } + + console.warn('[faceScanWorker] SQLite occupato: nuovo tentativo di completamento', { + photo_id: photoId, + retry: attempt + 1, + next_delay_ms: delays[attempt + 1], + }); + } + } + + throw new Error('Retry completamento esauriti'); +} + +async function processClaimedJob(job) { + if (!job || !job.photo_id) { + throw new Error('Job non valido'); + } + + const workerStartedAt = Date.now(); + + console.log( + '[faceScanWorker] processing', + { + photo_id: job.photo_id, + attempt: job.attempts, + }, + ); + + try { + const result = await scanPhotoFacesById( + job.photo_id, + ); + + let finalStatus; + + try { + finalStatus = await completeJobWithRetry( + job.photo_id, + result, + ); + } catch (writeError) { + const message = String(writeError?.message || writeError); + const code = String(writeError?.code || ''); + const isBusy = + code === 'SQLITE_BUSY' + || code === 'SQLITE_LOCKED' + || message.includes('database is locked') + || message.includes('SQLITE_BUSY') + || message.includes('SQLITE_LOCKED'); + + if (isBusy) { + console.error('[faceScanWorker] impossibile salvare il risultato dopo i retry', { + photo_id: job.photo_id, + attempt: job.attempts, + error: message, + }); + + try { + const updated = await db('face_scan_jobs') + .where({ + photo_id: job.photo_id, + status: 'processing', + }) + .update({ + status: 'pending', + last_error: 'Retry salvataggio risultato esauriti: ' + message, + started_at: null, + completed_at: null, + updated_at: db.fn.now(), + }); + + if (updated === 1) { + return { + status: 'pending', + photo_id: job.photo_id, + attempt: job.attempts, + rescan_required: true, + worker_ms: Date.now() - workerStartedAt, + }; + } + } catch (requeueError) { + console.error('[faceScanWorker] impossibile riaccodare il job; sarà recuperato come obsoleto', { + photo_id: job.photo_id, + error: String(requeueError?.message || requeueError), + }); + } + + return { + status: 'processing', + photo_id: job.photo_id, + attempt: job.attempts, + error: 'Salvataggio fallito; job recuperabile tramite reset stale', + rescan_required: false, + worker_ms: Date.now() - workerStartedAt, + }; + } + + throw writeError; + } + + const summary = { + status: finalStatus, + photo_id: job.photo_id, + attempt: job.attempts, + faces_detected: + finalStatus === 'done' + ? result.faces_detected + : null, + applied_rotation: + finalStatus === 'done' + ? result.applied_rotation + : null, + execution_ms: + finalStatus === 'done' + ? result.execution_ms + : null, + rescan_required: + finalStatus === 'pending', + worker_ms: Date.now() - workerStartedAt, + }; + + console.log( + '[faceScanWorker] done', + summary, + ); + + return summary; + } catch (error) { + const finalStatus = await failJob( + job.photo_id, + error, + ); + + const summary = { + status: finalStatus, + photo_id: job.photo_id, + attempt: job.attempts, + error: + finalStatus === 'error' + ? serializeError(error) + : null, + rescan_required: + finalStatus === 'pending', + worker_ms: Date.now() - workerStartedAt, + }; + + console.error( + '[faceScanWorker] error', + summary, + ); + + return summary; + } +} + +async function processNextJob() { + const job = await claimNextPendingJob(); + + if (!job) { + return null; + } + + return processClaimedJob(job); +} + +async function runWorker(options = {}) { + const maxJobs = + options.maxJobs == null + ? null + : Number(options.maxJobs); + + if ( + maxJobs !== null + && ( + !Number.isInteger(maxJobs) + || maxJobs <= 0 + ) + ) { + throw new Error( + 'maxJobs deve essere un intero positivo', + ); + } + + const shouldStop = + typeof options.shouldStop === 'function' + ? options.shouldStop + : () => false; + + const results = []; + + while ( + !shouldStop() + && ( + maxJobs === null + || results.length < maxJobs + ) + ) { + const result = await processNextJob(); + + if (!result) { + break; + } + + results.push(result); + } + + const succeeded = results.filter( + (result) => result.status === 'done', + ).length; + + const failed = results.filter( + (result) => result.status === 'error', + ).length; + + const rescheduled = results.filter( + (result) => result.status === 'pending', + ).length; + + return { + jobs_processed: results.length, + jobs_succeeded: succeeded, + jobs_failed: failed, + jobs_rescheduled: rescheduled, + stopped: shouldStop(), + results, + }; +} + +async function resetStaleProcessingJobs( + staleMinutes = 30, +) { + const minutes = Number(staleMinutes); + + if ( + !Number.isFinite(minutes) + || minutes <= 0 + ) { + throw new Error( + 'staleMinutes deve essere positivo', + ); + } + + const modifier = + `-${Math.floor(minutes)} minutes`; + + const updated = await db('face_scan_jobs') + .where({ status: 'processing' }) + .where( + 'started_at', + '<', + db.raw( + "datetime('now', ?)", + [modifier], + ), + ) + .update({ + status: 'pending', + last_error: + 'Job recuperato dopo arresto del worker', + started_at: null, + completed_at: null, + updated_at: db.fn.now(), + }); + + return updated; +} + +module.exports = { + serializeError, + claimNextPendingJob, + completeJob, + failJob, + processClaimedJob, + processNextJob, + runWorker, + resetStaleProcessingJobs, +}; diff --git a/api_v1/scanner/faceScanner.js b/api_v1/scanner/faceScanner.js new file mode 100644 index 0000000..154a7d8 --- /dev/null +++ b/api_v1/scanner/faceScanner.js @@ -0,0 +1,519 @@ +const fs = require('fs'); +const path = require('path'); +const { execFile } = require('child_process'); +const { promisify } = require('util'); + +const db = require('../../db/knex'); + +const { + resolveFilePathForEntryId, +} = require('../../utils/resolveFilePath'); + +const execFileAsync = promisify(execFile); + +const EMBEDDING_DIM = 128; +const EMBEDDING_BYTES = EMBEDDING_DIM * 4; + +const PROJECT_ROOT = + path.resolve(__dirname, '../..'); + +function getFaceScanBinary() { + if (process.env.FACE_SCAN_BINARY) { + return path.resolve( + process.env.FACE_SCAN_BINARY, + ); + } + + return path.join( + PROJECT_ROOT, + 'bin', + 'face_scan', + ); +} + +function isFiniteNumber(value) { + return ( + typeof value === 'number' + && Number.isFinite(value) + ); +} + +function validateImage(image) { + if (!image || typeof image !== 'object') { + throw new Error( + 'Risultato face_scan non valido: image mancante', + ); + } + + if ( + !Number.isInteger(image.width) + || image.width <= 0 + ) { + throw new Error( + `Larghezza immagine non valida: ${image.width}`, + ); + } + + if ( + !Number.isInteger(image.height) + || image.height <= 0 + ) { + throw new Error( + `Altezza immagine non valida: ${image.height}`, + ); + } + + if (typeof image.path !== 'string') { + throw new Error( + 'Percorso immagine non valido nel risultato face_scan', + ); + } +} + +function validateBoundingBox(bbox, faceIndex) { + if (!bbox || typeof bbox !== 'object') { + throw new Error( + `Bounding box mancante per face_index=${faceIndex}`, + ); + } + + for (const key of ['x', 'y', 'width', 'height']) { + if (!isFiniteNumber(bbox[key])) { + throw new Error( + `bbox.${key} non valido per face_index=${faceIndex}`, + ); + } + } + + if (bbox.width < 0 || bbox.height < 0) { + throw new Error( + `Bounding box negativa per face_index=${faceIndex}`, + ); + } +} + +function validateLandmarks(landmarks, faceIndex) { + if (!Array.isArray(landmarks)) { + throw new Error( + `Landmark non validi per face_index=${faceIndex}`, + ); + } + + if (landmarks.length !== 5) { + throw new Error( + `Landmark non validi per face_index=${faceIndex}: ` + + `attesi 5, ricevuti ${landmarks.length}`, + ); + } + + landmarks.forEach((point, landmarkIndex) => { + if ( + !Array.isArray(point) + || point.length !== 2 + || !isFiniteNumber(point[0]) + || !isFiniteNumber(point[1]) + ) { + throw new Error( + `Landmark ${landmarkIndex} non valido ` + + `per face_index=${faceIndex}`, + ); + } + }); +} + +function validateEmbedding(embedding, faceIndex) { + if (!Array.isArray(embedding)) { + throw new Error( + `Embedding non valido per face_index=${faceIndex}`, + ); + } + + if (embedding.length !== EMBEDDING_DIM) { + throw new Error( + `Embedding non valido per face_index=${faceIndex}: ` + + `attesi ${EMBEDDING_DIM}, ` + + `ricevuti ${embedding.length}`, + ); + } + + for ( + let embeddingIndex = 0; + embeddingIndex < embedding.length; + embeddingIndex += 1 + ) { + if (!isFiniteNumber(embedding[embeddingIndex])) { + throw new Error( + `Embedding non finito per face_index=${faceIndex}, ` + + `indice=${embeddingIndex}`, + ); + } + } + + const normSquared = embedding.reduce( + (sum, value) => sum + (value * value), + 0, + ); + + const norm = Math.sqrt(normSquared); + + if ( + !Number.isFinite(norm) + || Math.abs(norm - 1) > 0.001 + ) { + throw new Error( + `Embedding non normalizzato per face_index=${faceIndex}: ` + + `norm=${norm}`, + ); + } +} + +function validateFace(face, expectedIndex) { + if (!face || typeof face !== 'object') { + throw new Error( + `Volto non valido all'indice ${expectedIndex}`, + ); + } + + if ( + !Number.isInteger(face.face_index) + || face.face_index !== expectedIndex + ) { + throw new Error( + `face_index non valido: atteso ${expectedIndex}, ` + + `ricevuto ${face.face_index}`, + ); + } + + if (!isFiniteNumber(face.score)) { + throw new Error( + `Score non valido per face_index=${expectedIndex}`, + ); + } + + validateBoundingBox(face.bbox, expectedIndex); + validateLandmarks(face.landmarks, expectedIndex); + validateEmbedding(face.embedding, expectedIndex); +} + +function validateFaceScanResult(result) { + if (!result || typeof result !== 'object') { + throw new Error( + 'Risultato face_scan non valido', + ); + } + + validateImage(result.image); + + if (!Array.isArray(result.faces)) { + throw new Error( + 'Risultato face_scan non valido: faces non è un array', + ); + } + + result.faces.forEach((face, faceIndex) => { + validateFace(face, faceIndex); + }); + + return result; +} + +function embeddingToBlob(embedding) { + if ( + !Array.isArray(embedding) + || embedding.length !== EMBEDDING_DIM + ) { + throw new Error( + `Embedding non valido: attesi ${EMBEDDING_DIM} valori`, + ); + } + + const blob = Buffer.allocUnsafe(EMBEDDING_BYTES); + + embedding.forEach((value, index) => { + if (!isFiniteNumber(value)) { + throw new Error( + `Valore embedding non valido all'indice ${index}`, + ); + } + + blob.writeFloatLE(value, index * 4); + }); + + if (blob.length !== EMBEDDING_BYTES) { + throw new Error( + `Dimensione BLOB non valida: ${blob.length}`, + ); + } + + return blob; +} + +async function verifyFaceScanBinary() { + const binaryPath = getFaceScanBinary(); + + try { + await fs.promises.access( + binaryPath, + fs.constants.X_OK, + ); + } catch { + throw new Error( + `face_scan non trovato o non eseguibile: ${binaryPath}`, + ); + } + + return binaryPath; +} + +async function runFaceScan( + imagePath, + rotationDegrees = 0, +) { + const binaryPath = await verifyFaceScanBinary(); + + const normalizedRotation = + Number(rotationDegrees); + + if ( + !Number.isInteger(normalizedRotation) + || ![0, 90, 180, 270].includes(normalizedRotation) + ) { + throw new Error( + `Rotazione foto non valida: ${rotationDegrees}`, + ); + } + + const startedAt = Date.now(); + + let stdout; + let stderr; + + try { + const result = await execFileAsync( + binaryPath, + [ + '--rotation', + String(normalizedRotation), + imagePath, + ], + { + encoding: 'utf8', + timeout: Number( + process.env.FACE_SCAN_TIMEOUT_MS || 300000, + ), + maxBuffer: Number( + process.env.FACE_SCAN_MAX_BUFFER + || 64 * 1024 * 1024, + ), + windowsHide: true, + }, + ); + + stdout = result.stdout; + stderr = result.stderr; + } catch (error) { + const diagnostics = [ + error.message, + error.stderr, + ] + .filter(Boolean) + .join('\n') + .trim(); + + throw new Error( + `face_scan fallito per ${imagePath}` + + (diagnostics ? `\n${diagnostics}` : ''), + ); + } + + if (stderr && stderr.trim()) { + console.warn( + '[faceScanner] face_scan stderr:', + stderr.trim(), + ); + } + + if (!stdout || !stdout.trim()) { + throw new Error( + `face_scan non ha prodotto JSON per ${imagePath}`, + ); + } + + let parsed; + + try { + parsed = JSON.parse(stdout); + } catch (error) { + throw new Error( + `JSON face_scan non valido per ${imagePath}: ` + + error.message, + ); + } + + validateFaceScanResult(parsed); + + if ( + parsed.image.rotation !== normalizedRotation + ) { + throw new Error( + `Rotazione face_scan inattesa: richiesta ` + + `${normalizedRotation}, ricevuta ` + + `${parsed.image.rotation}`, + ); + } + + return { + ...parsed, + execution_ms: Date.now() - startedAt, + }; +} + +function faceToDatabaseRow(photoId, face) { + const landmarks = face.landmarks; + + return { + id: `face:${photoId}:${face.face_index}`, + photo_id: photoId, + face_index: face.face_index, + + x: face.bbox.x, + y: face.bbox.y, + width: face.bbox.width, + height: face.bbox.height, + + score: face.score, + + landmark_0_x: landmarks[0][0], + landmark_0_y: landmarks[0][1], + + landmark_1_x: landmarks[1][0], + landmark_1_y: landmarks[1][1], + + landmark_2_x: landmarks[2][0], + landmark_2_y: landmarks[2][1], + + landmark_3_x: landmarks[3][0], + landmark_3_y: landmarks[3][1], + + landmark_4_x: landmarks[4][0], + landmark_4_y: landmarks[4][1], + + embedding: embeddingToBlob(face.embedding), + + cluster_id: null, + person_id: null, + status: 'detected', + + created_at: db.fn.now(), + updated_at: db.fn.now(), + }; +} + +async function replacePhotoFaces(photoId, faces) { + const rows = faces.map((face) => ( + faceToDatabaseRow(photoId, face) + )); + + await db.transaction(async (trx) => { + await trx('faces') + .where({ photo_id: photoId }) + .del(); + + if (rows.length > 0) { + await trx('faces').insert(rows); + } + }); + + return rows.length; +} + +async function scanPhotoFacesById(photoId) { + const cleanPhotoId = String(photoId || '').trim(); + + if (!cleanPhotoId) { + throw new Error('photoId mancante'); + } + + const photo = await db('photos') + .where({ id: cleanPhotoId }) + .whereNull('deleted_at') + .first(); + + if (!photo) { + throw new Error( + `Foto non trovata o eliminata: ${cleanPhotoId}`, + ); + } + + if ( + photo.mime_type + && !String(photo.mime_type).startsWith('image/') + ) { + throw new Error( + `Il record non è un'immagine: ` + + `${photo.mime_type}`, + ); + } + + const imagePath = + await resolveFilePathForEntryId(cleanPhotoId); + + if (!imagePath) { + throw new Error( + `Percorso fisico non risolto per photo_id=${cleanPhotoId}`, + ); + } + + let fileStat; + + try { + fileStat = await fs.promises.stat(imagePath); + } catch (error) { + throw new Error( + `File non accessibile: ${imagePath}: ${error.message}`, + ); + } + + if (!fileStat.isFile()) { + throw new Error( + `Il percorso non è un file: ${imagePath}`, + ); + } + + const rotationDegrees = + photo.rotation == null + ? 0 + : Number(photo.rotation); + + const result = await runFaceScan( + imagePath, + rotationDegrees, + ); + + const insertedFaces = await replacePhotoFaces( + cleanPhotoId, + result.faces, + ); + + return { + photo_id: cleanPhotoId, + image_path: imagePath, + image_width: result.image.width, + image_height: result.image.height, + photo_rotation: photo.rotation ?? null, + applied_rotation: result.image.rotation, + faces_detected: result.faces.length, + faces_inserted: insertedFaces, + execution_ms: result.execution_ms, + }; +} + +module.exports = { + EMBEDDING_DIM, + EMBEDDING_BYTES, + embeddingToBlob, + validateFaceScanResult, + runFaceScan, + replacePhotoFaces, + scanPhotoFacesById, +}; diff --git a/api_v1/scanner/geocodeQueue.js b/api_v1/scanner/geocodeQueue.js new file mode 100644 index 0000000..7422431 --- /dev/null +++ b/api_v1/scanner/geocodeQueue.js @@ -0,0 +1,108 @@ +const crypto = require('crypto'); + +const db = require('../../db/knex'); + +function normalizeCoordinate(value) { + if (value == null || value === '') { + return null; + } + + const coordinate = Number(value); + + if (!Number.isFinite(coordinate)) { + return null; + } + + return coordinate; +} + +async function enqueueGeocodeJob({ + entityType, + entityId, + lat, + lon, +}) { + if ( + entityType !== 'photo' + && entityType !== 'activity' + ) { + throw new Error( + `Tipo entità geocode non valido: ${entityType}` + ); + } + + if (!entityId) { + throw new Error( + 'entityId geocode mancante' + ); + } + + const normalizedLat = normalizeCoordinate(lat); + const normalizedLon = normalizeCoordinate(lon); + + if ( + normalizedLat == null + || normalizedLon == null + ) { + return { + queued: false, + reason: 'coordinates_missing', + }; + } + + if ( + normalizedLat < -90 + || normalizedLat > 90 + || normalizedLon < -180 + || normalizedLon > 180 + ) { + return { + queued: false, + reason: 'coordinates_invalid', + }; + } + + const now = db.fn.now(); + + await db('geocode_jobs') + .insert({ + id: crypto.randomUUID(), + entity_type: entityType, + entity_id: String(entityId), + lat: normalizedLat, + lon: normalizedLon, + status: 'pending', + attempts: 0, + last_error: null, + started_at: null, + completed_at: null, + created_at: now, + updated_at: now, + }) + .onConflict([ + 'entity_type', + 'entity_id', + ]) + .merge({ + lat: normalizedLat, + lon: normalizedLon, + status: 'pending', + attempts: 0, + last_error: null, + started_at: null, + completed_at: null, + updated_at: now, + }); + + return { + queued: true, + entity_type: entityType, + entity_id: String(entityId), + lat: normalizedLat, + lon: normalizedLon, + }; +} + +module.exports = { + enqueueGeocodeJob, +}; diff --git a/api_v1/scanner/geocodeWorker.js b/api_v1/scanner/geocodeWorker.js new file mode 100644 index 0000000..46e8026 --- /dev/null +++ b/api_v1/scanner/geocodeWorker.js @@ -0,0 +1,551 @@ +const db = require('../../db/knex'); + +const loc = require('../geo'); +const getElevation = require('./elevation'); + +function serializeError(error) { + const value = + error?.stack + || error?.message + || String(error); + + return String(value).slice(0, 16000); +} + +function buildCacheKey(lat, lon) { + return ( + `${Number(lat).toFixed(4)}:` + + `${Number(lon).toFixed(4)}` + ); +} + +function parseLocation(value) { + if (!value) { + return null; + } + + if (typeof value === 'object') { + return value; + } + + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function serializeLocation(value) { + if (!value) { + return null; + } + + if (typeof value === 'string') { + return value; + } + + return JSON.stringify(value); +} + +async function claimNextPendingJob() { + return db.transaction(async (trx) => { + const job = await trx('geocode_jobs') + .where({ status: 'pending' }) + .orderBy('created_at', 'asc') + .orderBy('id', 'asc') + .first(); + + if (!job) { + return null; + } + + const updated = await trx('geocode_jobs') + .where({ + id: job.id, + status: 'pending', + }) + .update({ + status: 'processing', + attempts: trx.raw('attempts + 1'), + last_error: null, + started_at: trx.fn.now(), + completed_at: null, + updated_at: trx.fn.now(), + }); + + if (updated !== 1) { + return null; + } + + return trx('geocode_jobs') + .where({ id: job.id }) + .first(); + }); +} + +async function completeJob(jobId) { + const updated = await db('geocode_jobs') + .where({ + id: jobId, + status: 'processing', + }) + .update({ + status: 'done', + last_error: null, + completed_at: db.fn.now(), + updated_at: db.fn.now(), + }); + + if (updated !== 1) { + throw new Error( + `Impossibile completare il job id=${jobId}`, + ); + } + + return 'done'; +} + +async function failJob(jobId, error) { + const updated = await db('geocode_jobs') + .where({ + id: jobId, + status: 'processing', + }) + .update({ + status: 'error', + last_error: serializeError(error), + completed_at: db.fn.now(), + updated_at: db.fn.now(), + }); + + if (updated !== 1) { + console.error( + '[geocodeWorker] impossibile registrare errore:', + jobId, + ); + + return 'missing'; + } + + return 'error'; +} + +async function loadEntity(job) { + if (job.entity_type === 'photo') { + return db('photos') + .select([ + 'id', + 'lat', + 'lon', + 'alt', + 'location', + ]) + .where({ id: job.entity_id }) + .whereNull('deleted_at') + .first(); + } + + if (job.entity_type === 'activity') { + return db('activities') + .select([ + 'id', + 'center_latitude', + 'center_longitude', + 'location', + ]) + .where({ id: job.entity_id }) + .whereNull('deleted_at') + .first(); + } + + throw new Error( + `Tipo entità non valido: ${job.entity_type}`, + ); +} + +async function loadCache(cacheKey) { + return db('geocode_cache') + .where({ cache_key: cacheKey }) + .first(); +} + +async function saveCache({ + cacheKey, + lat, + lon, + alt, + location, +}) { + const existing = await loadCache(cacheKey); + + const finalAlt = + alt != null + ? Number(alt) + : existing?.alt ?? null; + + const finalLocation = + serializeLocation(location) + || existing?.location + || null; + + const now = db.fn.now(); + + await db('geocode_cache') + .insert({ + cache_key: cacheKey, + lat, + lon, + alt: finalAlt, + location: finalLocation, + hits: existing?.hits ?? 0, + created_at: + existing?.created_at || now, + updated_at: now, + }) + .onConflict('cache_key') + .merge({ + lat, + lon, + alt: finalAlt, + location: finalLocation, + updated_at: now, + }); +} + +async function recordCacheHit(cacheKey) { + await db('geocode_cache') + .where({ cache_key: cacheKey }) + .update({ + hits: db.raw('hits + 1'), + updated_at: db.fn.now(), + }); +} + +async function updateEntity({ + job, + alt, + location, +}) { + const locationJson = + serializeLocation(location); + + if (job.entity_type === 'photo') { + const updated = await db('photos') + .where({ id: job.entity_id }) + .whereNull('deleted_at') + .update({ + alt: alt ?? null, + location: locationJson, + updated_at: db.fn.now(), + }); + + if (updated !== 1) { + throw new Error( + `Foto non trovata: ${job.entity_id}`, + ); + } + + return; + } + + if (job.entity_type === 'activity') { + const updated = await db('activities') + .where({ id: job.entity_id }) + .whereNull('deleted_at') + .update({ + location: locationJson, + updated_at: db.fn.now(), + }); + + if (updated !== 1) { + throw new Error( + `Attività non trovata: ${job.entity_id}`, + ); + } + + return; + } + + throw new Error( + `Tipo entità non valido: ${job.entity_type}`, + ); +} + +async function processClaimedJob(job) { + if (!job?.id || !job?.entity_id) { + throw new Error('Job geocode non valido'); + } + + const workerStartedAt = Date.now(); + + console.log( + '[geocodeWorker] processing', + { + job_id: job.id, + entity_type: job.entity_type, + entity_id: job.entity_id, + attempt: job.attempts, + }, + ); + + try { + const lat = Number(job.lat); + const lon = Number(job.lon); + + if ( + !Number.isFinite(lat) + || !Number.isFinite(lon) + || lat < -90 + || lat > 90 + || lon < -180 + || lon > 180 + ) { + throw new Error( + `Coordinate non valide: lat=${job.lat}, lon=${job.lon}`, + ); + } + + const entity = await loadEntity(job); + + if (!entity) { + throw new Error( + `Entità non trovata: ` + + `${job.entity_type}/${job.entity_id}`, + ); + } + + const cacheKey = buildCacheKey(lat, lon); + const cached = await loadCache(cacheKey); + + const altitudeFromCache = + job.entity_type === 'photo' + && entity.alt == null + && cached?.alt != null; + + const locationFromCache = + !parseLocation(entity.location) + && Boolean( + parseLocation(cached?.location) + ); + + const cacheUsed = + altitudeFromCache + || locationFromCache; + + let altitude = + job.entity_type === 'photo' + ? entity.alt ?? cached?.alt ?? null + : null; + + let location = + parseLocation(entity.location) + || parseLocation(cached?.location) + || null; + + if (cacheUsed) { + await recordCacheHit(cacheKey); + } + + if ( + job.entity_type === 'photo' + && altitude == null + ) { + altitude = await getElevation( + lat, + lon, + ); + } + + if (!location) { + location = await loc( + lon, + lat, + ); + } + + if (!location) { + throw new Error( + `Reverse geocoding senza risultato: ` + + `lat=${lat}, lon=${lon}`, + ); + } + + await saveCache({ + cacheKey, + lat, + lon, + alt: altitude, + location, + }); + + await updateEntity({ + job, + alt: altitude, + location, + }); + + const finalStatus = + await completeJob(job.id); + + const summary = { + status: finalStatus, + job_id: job.id, + entity_type: job.entity_type, + entity_id: job.entity_id, + attempt: job.attempts, + cache_key: cacheKey, + cache_used: cacheUsed, + altitude_from_cache: altitudeFromCache, + location_from_cache: locationFromCache, + altitude: + job.entity_type === 'photo' + ? altitude + : null, + worker_ms: + Date.now() - workerStartedAt, + }; + + console.log( + '[geocodeWorker] done', + summary, + ); + + return summary; + } catch (error) { + const finalStatus = await failJob( + job.id, + error, + ); + + const summary = { + status: finalStatus, + job_id: job.id, + entity_type: job.entity_type, + entity_id: job.entity_id, + attempt: job.attempts, + error: serializeError(error), + worker_ms: + Date.now() - workerStartedAt, + }; + + console.error( + '[geocodeWorker] error', + summary, + ); + + return summary; + } +} + +async function processNextJob() { + const job = await claimNextPendingJob(); + + if (!job) { + return null; + } + + return processClaimedJob(job); +} + +async function runWorker(options = {}) { + const maxJobs = + options.maxJobs == null + ? null + : Number(options.maxJobs); + + if ( + maxJobs !== null + && ( + !Number.isInteger(maxJobs) + || maxJobs <= 0 + ) + ) { + throw new Error( + 'maxJobs deve essere un intero positivo', + ); + } + + const shouldStop = + typeof options.shouldStop === 'function' + ? options.shouldStop + : () => false; + + const results = []; + + while ( + !shouldStop() + && ( + maxJobs === null + || results.length < maxJobs + ) + ) { + const result = await processNextJob(); + + if (!result) { + break; + } + + results.push(result); + } + + return { + jobs_processed: results.length, + jobs_succeeded: results.filter( + (result) => result.status === 'done', + ).length, + jobs_failed: results.filter( + (result) => result.status === 'error', + ).length, + stopped: shouldStop(), + results, + }; +} + +async function resetStaleProcessingJobs( + staleMinutes = 30, +) { + const minutes = Number(staleMinutes); + + if ( + !Number.isFinite(minutes) + || minutes <= 0 + ) { + throw new Error( + 'staleMinutes deve essere positivo', + ); + } + + const modifier = + `-${Math.floor(minutes)} minutes`; + + return db('geocode_jobs') + .where({ status: 'processing' }) + .where( + 'started_at', + '<', + db.raw( + "datetime('now', ?)", + [modifier], + ), + ) + .update({ + status: 'pending', + last_error: + 'Job recuperato dopo arresto del worker', + started_at: null, + completed_at: null, + updated_at: db.fn.now(), + }); +} + +module.exports = { + serializeError, + buildCacheKey, + claimNextPendingJob, + completeJob, + failJob, + processClaimedJob, + processNextJob, + runWorker, + resetStaleProcessingJobs, +}; diff --git a/api_v1/scanner/gps.js b/api_v1/scanner/gps.js new file mode 100644 index 0000000..b6f1bb0 --- /dev/null +++ b/api_v1/scanner/gps.js @@ -0,0 +1,69 @@ +const { exec } = require('child_process'); + +// ----------------------------------------------------------------------------- +// FOTO: GPS da ExifReader +// ----------------------------------------------------------------------------- +function extractGpsFromExif(tags) { + if (!tags?.gps) return null; + + const lat = tags.gps.Latitude; + const lng = tags.gps.Longitude; + const alt = tags.gps.Altitude; + + if (lat == null || lng == null) return null; + + return { + lat: Number(lat), + lng: Number(lng), + alt: alt != null ? Number(alt) : null + }; +} + +// ----------------------------------------------------------------------------- +// VIDEO: GPS via exiftool (VERSIONE ORIGINALE CHE FUNZIONA) +// ----------------------------------------------------------------------------- +function extractGpsWithExiftool(videoPath) { + //console.log(videoPath); + return new Promise((resolve) => { + const cmd = `exiftool -n -G1 -a -gps:all -quicktime:all -user:all "${videoPath}"`; + + exec(cmd, (err, stdout) => { + if (err || !stdout) return resolve(null); + + // 1) GPS Coordinates : + const userData = stdout.match(/GPS Coordinates\s*:\s*([0-9.\-]+)\s+([0-9.\-]+)/i); + if (userData) { + return resolve({ + lat: Number(userData[1]), + lng: Number(userData[2]), + alt: null + }); + } + + // 2) GPSLatitude / GPSLongitude + const lat1 = stdout.match(/GPSLatitude\s*:\s*([0-9.\-]+)/i); + const lng1 = stdout.match(/GPSLongitude\s*:\s*([0-9.\-]+)/i); + if (lat1 && lng1) { + return resolve({ + lat: Number(lat1[1]), + lng: Number(lng1[1]), + alt: null + }); + } + + // 3) GPSCoordinates : + const coords = stdout.match(/GPSCoordinates\s*:\s*([0-9.\-]+)\s+([0-9.\-]+)/i); + if (coords) { + return resolve({ + lat: Number(coords[1]), + lng: Number(coords[2]), + alt: null + }); + } + + resolve(null); + }); + }); +} + +module.exports = { extractGpsFromExif, extractGpsWithExiftool }; diff --git a/api_v1/scanner/logger.js b/api_v1/scanner/logger.js new file mode 100644 index 0000000..1e8abde --- /dev/null +++ b/api_v1/scanner/logger.js @@ -0,0 +1,43 @@ +// scanner/logger.js +const fs = require('fs'); +const path = require('path'); + +const LOG_MODE = process.env.LOG_MODE || "console"; // console | file | both +const LOG_DIR = process.env.LOG_DIR || null; +const LOG_FILE = process.env.LOG_FILE || "scan.log"; + +let stream = null; + +function resolveLogPath() { + if (LOG_DIR) { + return path.resolve(__dirname, "..", "..", LOG_DIR, LOG_FILE); + } + return path.resolve(__dirname, "..", "..", LOG_FILE); +} + +if (LOG_MODE === "file" || LOG_MODE === "both") { + const logPath = resolveLogPath(); + + // crea la directory se non esiste + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + + stream = fs.createWriteStream(logPath, { flags: "a" }); +} + +function ts() { + return new Date().toISOString().replace("T", " ").split(".")[0]; +} + +function log(message) { + const line = `${ts()} ${message}\n`; + + if (LOG_MODE === "console" || LOG_MODE === "both") { + process.stdout.write(line); + } + + if (LOG_MODE === "file" || LOG_MODE === "both") { + stream.write(line); + } +} + +module.exports = { log }; diff --git a/api_v1/scanner/orphanCleanup.js b/api_v1/scanner/orphanCleanup.js new file mode 100644 index 0000000..2bd1745 --- /dev/null +++ b/api_v1/scanner/orphanCleanup.js @@ -0,0 +1,109 @@ +// api_v1/scanner/orphanCleanup.js +const fsp = require("fs/promises"); +const path = require("path"); + +module.exports = function createCleanupFunctions(db, RETENTION_DAYS = 30) { + // 1) Recupera gli ID dal DB per una cartella specifica + async function buildIdsListForFolder(userName, cartella) { + try { + const rows = await db("photos") + .select("id") + .where({ user: userName, cartella }); + + return rows.map((r) => r.id); + } catch (err) { + console.error("Errore buildIdsListForFolder:", err); + return []; + } + } + + // 2) Rimuove un ID dalla lista (invariati) + function removeIdFromList(idsIndex, id) { + return idsIndex.filter((x) => x !== id); + } + + // 3) Cancella thumbs dal filesystem + async function deleteThumbsById(id) { + const rec = await db("photos").where({ id }).first(); + if (!rec) return false; + + const thumbs = [rec.thub1, rec.thub2, rec.cast].filter(Boolean); + let deleted = false; + + for (const t of thumbs) { + const abs = path.resolve(__dirname, "..", "..", "public", t); + + // 🔍 Log del path reale + console.log(` 📁 Tentativo cancellazione thumb: ${abs}`); + + try { + await fsp.rm(abs, { force: true }); + console.log(` 🔴 Thumb eliminato: ${abs}`); + deleted = true; + } catch {} + } + + return deleted; + } + + // 4) Soft delete + Hard delete con retention + async function deleteFromDB(id, userName) { + const now = new Date(); + const nowIso = now.toISOString(); + + // Recupera record attuale + const rec = await db("photos").where({ id, user: userName }).first(); + + if (!rec) { + console.log(`⚠️ deleteFromDB: foto ${id} non trovata`); + return false; + } + + // Se NON era già soft-deleted → SOFT DELETE + if (!rec.deleted_at) { + await db("photos").where({ id, user: userName }).update({ + deleted_at: nowIso, + updated_at: nowIso, + }); + + console.log(`🟡 Soft delete → id=${id}`); + return true; + } + + // Se ERA già soft-deleted → controlla retention + const deletedAt = new Date(rec.deleted_at); + const retentionMs = RETENTION_DAYS * 24 * 60 * 60 * 1000; + + const shouldHardDelete = now - deletedAt > retentionMs; + + if (!shouldHardDelete) { + console.log(`🟡 Soft delete già presente → id=${id} (in retention)`); + return true; + } + + // 🔥 HARD DELETE + console.log(`🔴 HARD DELETE → id=${id}`); + + // 1) Elimina thumbs + await deleteThumbsById(id); + + // 2) Elimina record dal DB + await db("photos").where({ id, user: userName }).del(); + + // 3) Registra hard delete per progressive sync + await db("deleted_hard").insert({ + id, + user: userName, + deleted_at: nowIso, + }); + + return true; + } + + return { + buildIdsListForFolder, + removeIdFromList, + deleteThumbsById, + deleteFromDB, + }; +}; diff --git a/api_v1/scanner/parseActivityFile.js b/api_v1/scanner/parseActivityFile.js new file mode 100644 index 0000000..ff5eb5c --- /dev/null +++ b/api_v1/scanner/parseActivityFile.js @@ -0,0 +1,100 @@ +// api_v1/scanner/parseActivityFile.js +const path = require('path'); + +// Parser specifici +const parseFit = require('./parsers/parseFit'); +const parseGpx = require('./parsers/parseGpx'); +const parseTcx = require('./parsers/parseTcx'); + +// --------------------------------------------------------- +// parseActivityFile → rileva tipo e chiama parser specifico +// --------------------------------------------------------- +async function parseActivityFile(absPath) { + const ext = path.extname(absPath).toLowerCase(); + + let parsed; + + if (ext === '.fit') { + parsed = await parseFit(absPath); + } else if (ext === '.gpx') { + parsed = await parseGpx(absPath); + } else if (ext === '.tcx') { + parsed = await parseTcx(absPath); + } else { + throw new Error(`Formato attività non supportato: ${ext}`); + } + + // --------------------------------------------------------- + // Normalizzazione campi base + // --------------------------------------------------------- + const fileName = path.basename(absPath); + const fileExtension = ext.replace('.', ''); + + return { + file_name: fileName, + file_extension: fileExtension, + format: parsed.format || fileExtension.toUpperCase(), + + display_name: parsed.display_name || fileName, + route_name: parsed.route_name || null, + title: parsed.title || null, + description: parsed.description || null, + notes: parsed.notes || null, + + sport: parsed.sport || null, + activity_type: parsed.activity_type || null, + device_manufacturer: parsed.device_manufacturer || null, + device_model: parsed.device_model || null, + source_app: parsed.source_app || null, + + start_time_millis: parsed.start_time_millis ?? null, + end_time_millis: parsed.end_time_millis ?? null, + duration_millis: parsed.duration_millis ?? null, + + distance_meters: parsed.distance_meters ?? null, + elevation_gain_meters: parsed.elevation_gain_meters ?? null, + elevation_loss_meters: parsed.elevation_loss_meters ?? null, + + min_elevation_meters: parsed.min_elevation_meters ?? null, + max_elevation_meters: parsed.max_elevation_meters ?? null, + + file_distance_meters: parsed.file_distance_meters ?? null, + file_elevation_gain_meters: parsed.file_elevation_gain_meters ?? null, + + computed_distance_meters: parsed.computed_distance_meters ?? null, + computed_elevation_gain_meters: parsed.computed_elevation_gain_meters ?? null, + + average_speed_mps: parsed.average_speed_mps ?? null, + max_speed_mps: parsed.max_speed_mps ?? null, + average_pace_sec_per_km: parsed.average_pace_sec_per_km ?? null, + + average_heart_rate: parsed.average_heart_rate ?? null, + max_heart_rate: parsed.max_heart_rate ?? null, + average_cadence: parsed.average_cadence ?? null, + max_cadence: parsed.max_cadence ?? null, + + calories: parsed.calories ?? null, + power_avg: parsed.power_avg ?? null, + power_max: parsed.power_max ?? null, + + point_count: parsed.point_count ?? 0, + segment_count: parsed.segment_count ?? 0, + lap_count: parsed.lap_count ?? 0, + + min_latitude: parsed.min_latitude ?? null, + max_latitude: parsed.max_latitude ?? null, + min_longitude: parsed.min_longitude ?? null, + max_longitude: parsed.max_longitude ?? null, + center_latitude: parsed.center_latitude ?? null, + center_longitude: parsed.center_longitude ?? null, + + tags: parsed.tags || [], + + // IMPORTANTISSIMO: preview percorso per thumb gallery + track_preview: parsed.track_preview || [], + + diagnostics: parsed.diagnostics || null, + }; +} + +module.exports = parseActivityFile; diff --git a/api_v1/scanner/parsers/parseFit.js b/api_v1/scanner/parsers/parseFit.js new file mode 100644 index 0000000..6077acb --- /dev/null +++ b/api_v1/scanner/parsers/parseFit.js @@ -0,0 +1,408 @@ +// api_v1/scanner/parsers/parseFit.js +const { execFile } = require("child_process"); + +function readFitFrames(absPath) { + return new Promise( + (resolve, reject) => { + execFile( + "/home/orangepi/.local/pipx/venvs/fitdecode/bin/python", + [ + "api_v1/scanner/parsers/parse_fit.py", + absPath, + ], + { + maxBuffer: + 1024 * 1024 * 50, + }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + + try { + resolve( + JSON.parse(stdout), + ); + } catch (parseError) { + reject( + new Error( + "Errore nel parsing JSON FIT: " + + parseError.message, + ), + ); + } + }, + ); + }, + ); +} + +// FIT position_lat / position_long sono in semicircles. +// Conversione ufficiale: degrees = semicircles * 180 / 2^31 +function fitSemicirclesToDegrees(value) { + if (value == null) return null; + + const n = Number(value); + if (!Number.isFinite(n)) return null; + + return n * (180 / 2147483648); +} + +function buildTrackPointsFromFitRecords( + records, +) { + if (!Array.isArray(records)) { + return []; + } + + const points = []; + + for (const record of records) { + const fields = + record?.fields || {}; + + const rawLat = + fields.position_lat ?? + fields.latitude ?? + fields.lat; + + const rawLng = + fields.position_long ?? + fields.position_lon ?? + fields.longitude ?? + fields.lng ?? + fields.lon; + + const lat = + fitSemicirclesToDegrees( + rawLat, + ); + + const lng = + fitSemicirclesToDegrees( + rawLng, + ); + + if ( + !Number.isFinite(lat) || + !Number.isFinite(lng) + ) { + continue; + } + + const point = { + lat, + lng, + }; + +const altitudeRaw = + fields.altitude ?? + fields.enhanced_altitude; + + const altitude = + altitudeRaw != null + ? Number(altitudeRaw) + : null; + + if ( + Number.isFinite(altitude) + ) { + point.alt = altitude; + } + + points.push(point); + } + + return points; +} + +function buildTrackPreviewFromFitRecords( + records, + maxPoints = 300, +) { + const points = + buildTrackPointsFromFitRecords( + records, + ); + + if ( + points.length <= maxPoints + ) { + return points; + } + + const step = + Math.ceil( + points.length / maxPoints, + ); + + const preview = []; + + for ( + let index = 0; + index < points.length; + index += step + ) { + preview.push( + points[index], + ); + } + + const last = + points.at(-1); + + const previewLast = + preview.at(-1); + + if ( + last && + ( + !previewLast || + previewLast.lat !== + last.lat || + previewLast.lng !== + last.lng + ) + ) { + preview.push(last); + } + + return preview; +} + +async function parseFit(absPath) { + const frames = + await readFitFrames( + absPath, + ); + + // ------------------------------- + // 1. Trova i frame principali + // ------------------------------- + const sessions = frames.filter(f => f.name === "session"); + const laps = frames.filter(f => f.name === "lap"); + const records = frames.filter(f => f.name === "record"); + const fileId = frames.find(f => f.name === "file_id"); + + const session = sessions[0]?.fields || {}; + const file = fileId?.fields || {}; + + // ------------------------------- + // 2. Tempi + // ------------------------------- + const startTime = session.start_time || session.start_time_local || null; + + const endTime = session.total_timer_time && startTime + ? new Date(new Date(startTime).getTime() + session.total_timer_time * 1000) + : null; + + const startMillis = startTime ? new Date(startTime).getTime() : null; + const endMillis = endTime ? endTime.getTime() : null; + + const durationMillis = session.total_timer_time + ? session.total_timer_time * 1000 + : endMillis && startMillis + ? endMillis - startMillis + : null; + + // ------------------------------- + // 3. Distanza / Elevazione + // ------------------------------- + const distance = session.total_distance || null; + const elevGain = session.total_ascent || null; + const elevLoss = session.total_descent || null; + + // ------------------------------- + // 4. HR / Cadenza / Velocità + // ------------------------------- + const avgHr = session.avg_heart_rate || null; + const maxHr = session.max_heart_rate || null; + + const avgCad = session.avg_cadence || null; + const maxCad = session.max_cadence || null; + + const avgSpeed = session.avg_speed || null; + const maxSpeed = session.max_speed || null; + + // ------------------------------- + // 5. Bounding box GPS + // ------------------------------- + let minLat = null; + let maxLat = null; + let minLon = null; + let maxLon = null; + let minElev = null; + let maxElev = null; + + for (const r of records) { + const f = r.fields || {}; + + const lat = fitSemicirclesToDegrees(f.position_lat); + const lon = fitSemicirclesToDegrees(f.position_long); + + const eleRaw = + f.altitude ?? + f.enhanced_altitude; + + const ele = eleRaw != null ? Number(eleRaw) : null; + + if (Number.isFinite(lat) && Number.isFinite(lon)) { + minLat = minLat == null ? lat : Math.min(minLat, lat); + maxLat = maxLat == null ? lat : Math.max(maxLat, lat); + minLon = minLon == null ? lon : Math.min(minLon, lon); + maxLon = maxLon == null ? lon : Math.max(maxLon, lon); + } + + if (Number.isFinite(ele)) { + minElev = minElev == null ? ele : Math.min(minElev, ele); + maxElev = maxElev == null ? ele : Math.max(maxElev, ele); + } + } + + const centerLat = + minLat != null && maxLat != null ? (minLat + maxLat) / 2 : null; + + const centerLon = + minLon != null && maxLon != null ? (minLon + maxLon) / 2 : null; + + // ------------------------------- + // 6. Track preview per gallery + // ------------------------------- + const trackPreview = buildTrackPreviewFromFitRecords(records, 300); + + // Log utile temporaneo. Puoi rimuoverlo dopo il test. + console.log("[parseFit] track preview", { + file: absPath, + records: records.length, + preview: trackPreview.length, + first: trackPreview[0], + minLat, + maxLat, + minLon, + maxLon, + }); + + // ------------------------------- + // 7. Costruzione oggetto attività finale + // ------------------------------- + return { + format: "FIT", + + display_name: session.sport || null, + route_name: session.sport || null, + title: session.sport || null, + description: null, + notes: null, + + sport: session.sport || null, + activity_type: session.sub_sport || null, + device_manufacturer: file.manufacturer || null, + device_model: file.product || null, + source_app: null, + + start_time_millis: startMillis, + end_time_millis: endMillis, + duration_millis: durationMillis, + + distance_meters: distance, + elevation_gain_meters: elevGain, + elevation_loss_meters: elevLoss, + + min_elevation_meters: minElev, + max_elevation_meters: maxElev, + + file_distance_meters: distance, + file_elevation_gain_meters: elevGain, + + computed_distance_meters: distance, + computed_elevation_gain_meters: elevGain, + + average_speed_mps: avgSpeed, + max_speed_mps: maxSpeed, + average_pace_sec_per_km: avgSpeed ? 1000 / avgSpeed : null, + + average_heart_rate: avgHr, + max_heart_rate: maxHr, + average_cadence: avgCad, + max_cadence: maxCad, + + calories: session.total_calories || null, + power_avg: session.avg_power || null, + power_max: session.max_power || null, + + point_count: records.length, + segment_count: laps.length, + lap_count: laps.length, + + min_latitude: minLat, + max_latitude: maxLat, + min_longitude: minLon, + max_longitude: maxLon, + center_latitude: centerLat, + center_longitude: centerLon, + + // Preview ridotta del percorso per i thumb della gallery + track_preview: trackPreview, + + tags: [], + + diagnostics: { + source: "FIT", + sessions: sessions.length, + laps: laps.length, + records: records.length, + track_preview_points: trackPreview.length, + coordinates_converted_from_semicircles: true, + }, + }; +} + +async function parseFitTrackPoints( + absPath, +) { + if (!absPath) { + return []; + } + + const frames = + await readFitFrames( + absPath, + ); + + const records = + frames.filter( + (frame) => + frame?.name === + "record", + ); + + const points = + buildTrackPointsFromFitRecords( + records, + ); + +/* + console.log( + "[parseFitTrackPoints] traccia completa", + { + file: + absPath, + + records: + records.length, + + gpsPoints: + points.length, + }, + ); +*/ + + return points; +} + +module.exports = + parseFit; + +module.exports.parseFitTrackPoints = + parseFitTrackPoints; diff --git a/api_v1/scanner/parsers/parseGpx.js b/api_v1/scanner/parsers/parseGpx.js new file mode 100644 index 0000000..676f18f --- /dev/null +++ b/api_v1/scanner/parsers/parseGpx.js @@ -0,0 +1,204 @@ +// api_v1/scanner/parsers/parseGpx.js +const fsp = require('fs/promises'); +const xml2js = require('xml2js'); // npm install xml2js + +function toMillis(date) { + if (!date) return null; + const d = new Date(date); + return isNaN(d.getTime()) ? null : d.getTime(); +} + +function buildTrackPreviewFromGpxPoints(points, maxPoints = 300) { + if (!Array.isArray(points)) return []; + + const clean = []; + + for (const p of points) { + const lat = p.$?.lat != null ? Number(p.$.lat) : null; + const lng = p.$?.lon != null ? Number(p.$.lon) : null; + const alt = p.ele != null ? Number(p.ele) : null; + + if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue; + + const point = { lat, lng }; + + if (Number.isFinite(alt)) { + point.alt = alt; + } + + clean.push(point); + } + + if (clean.length <= maxPoints) return clean; + + const step = Math.ceil(clean.length / maxPoints); + const preview = []; + + for (let i = 0; i < clean.length; i += step) { + preview.push(clean[i]); + } + + const last = clean[clean.length - 1]; + const currentLast = preview[preview.length - 1]; + + if ( + !currentLast || + currentLast.lat !== last.lat || + currentLast.lng !== last.lng + ) { + preview.push(last); + } + + return preview; +} + +async function parseGpx(absPath) { + const xml = await fsp.readFile(absPath, 'utf8'); + + const parser = new xml2js.Parser({ explicitArray: false }); + const gpx = await parser.parseStringPromise(xml); + + const root = gpx.gpx || gpx; + const trk = Array.isArray(root.trk) ? root.trk[0] : root.trk; + const name = trk?.name || null; + + const segments = trk?.trkseg + ? (Array.isArray(trk.trkseg) ? trk.trkseg : [trk.trkseg]) + : []; + + const points = []; + + for (const seg of segments) { + const segPts = seg.trkpt + ? (Array.isArray(seg.trkpt) ? seg.trkpt : [seg.trkpt]) + : []; + + points.push(...segPts); + } + + let minLat = null; + let maxLat = null; + let minLon = null; + let maxLon = null; + let minElev = null; + let maxElev = null; + + let startTime = null; + let endTime = null; + let distance = null; + + for (const p of points) { + const lat = p.$?.lat != null ? Number(p.$.lat) : null; + const lon = p.$?.lon != null ? Number(p.$.lon) : null; + const ele = p.ele != null ? Number(p.ele) : null; + const time = p.time || null; + + if (Number.isFinite(lat) && Number.isFinite(lon)) { + minLat = minLat == null ? lat : Math.min(minLat, lat); + maxLat = maxLat == null ? lat : Math.max(maxLat, lat); + minLon = minLon == null ? lon : Math.min(minLon, lon); + maxLon = maxLon == null ? lon : Math.max(maxLon, lon); + } + + if (Number.isFinite(ele)) { + minElev = minElev == null ? ele : Math.min(minElev, ele); + maxElev = maxElev == null ? ele : Math.max(maxElev, ele); + } + + if (time) { + if (!startTime) startTime = time; + endTime = time; + } + } + + const start_time_millis = toMillis(startTime); + const end_time_millis = toMillis(endTime); + + const duration_millis = + start_time_millis && end_time_millis + ? end_time_millis - start_time_millis + : null; + + const centerLat = + minLat != null && maxLat != null + ? (minLat + maxLat) / 2 + : null; + + const centerLon = + minLon != null && maxLon != null + ? (minLon + maxLon) / 2 + : null; + + const trackPreview = buildTrackPreviewFromGpxPoints(points, 300); + + return { + format: 'GPX', + + display_name: name, + route_name: name, + title: name, + description: root.metadata?.desc || null, + notes: null, + + sport: root.metadata?.type || null, + activity_type: null, + device_manufacturer: root.metadata?.author?.name || null, + device_model: null, + source_app: root.metadata?.author?.name || null, + + start_time_millis, + end_time_millis, + duration_millis, + + distance_meters: distance, + elevation_gain_meters: null, + elevation_loss_meters: null, + + min_elevation_meters: minElev, + max_elevation_meters: maxElev, + + file_distance_meters: distance, + file_elevation_gain_meters: null, + + computed_distance_meters: distance, + computed_elevation_gain_meters: null, + + average_speed_mps: null, + max_speed_mps: null, + average_pace_sec_per_km: null, + + average_heart_rate: null, + max_heart_rate: null, + average_cadence: null, + max_cadence: null, + + calories: null, + power_avg: null, + power_max: null, + + point_count: points.length, + segment_count: segments.length, + lap_count: 0, + + min_latitude: minLat, + max_latitude: maxLat, + min_longitude: minLon, + max_longitude: maxLon, + center_latitude: centerLat, + center_longitude: centerLon, + + // Preview ridotta del percorso per i thumb della gallery + track_preview: trackPreview, + + tags: [], + + diagnostics: { + source: 'GPX', + segments: segments.length, + points: points.length, + track_preview_points: trackPreview.length + } + }; +} + +module.exports = parseGpx; diff --git a/api_v1/scanner/parsers/parseTcx.js b/api_v1/scanner/parsers/parseTcx.js new file mode 100644 index 0000000..9d9a694 --- /dev/null +++ b/api_v1/scanner/parsers/parseTcx.js @@ -0,0 +1,298 @@ +// api_v1/scanner/parsers/parseTcx.js +const fsp = require('fs/promises'); +const xml2js = require('xml2js'); + +function toMillis(date) { + if (!date) return null; + const d = new Date(date); + return isNaN(d.getTime()) ? null : d.getTime(); +} + +function buildTrackPreviewFromTcxPoints(points, maxPoints = 300) { + if (!Array.isArray(points)) return []; + + const clean = []; + + for (const p of points) { + const pos = p.Position || {}; + + const lat = pos.LatitudeDegrees != null + ? Number(pos.LatitudeDegrees) + : null; + + const lng = pos.LongitudeDegrees != null + ? Number(pos.LongitudeDegrees) + : null; + + const alt = p.AltitudeMeters != null + ? Number(p.AltitudeMeters) + : null; + + if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue; + + const point = { lat, lng }; + + if (Number.isFinite(alt)) { + point.alt = alt; + } + + clean.push(point); + } + + if (clean.length <= maxPoints) return clean; + + const step = Math.ceil(clean.length / maxPoints); + const preview = []; + + for (let i = 0; i < clean.length; i += step) { + preview.push(clean[i]); + } + + const last = clean[clean.length - 1]; + const currentLast = preview[preview.length - 1]; + + if ( + !currentLast || + currentLast.lat !== last.lat || + currentLast.lng !== last.lng + ) { + preview.push(last); + } + + return preview; +} + +async function parseTcx(absPath) { + const xml = await fsp.readFile(absPath, 'utf8'); + + const parser = new xml2js.Parser({ explicitArray: false }); + const tcx = await parser.parseStringPromise(xml); + + const root = tcx.TrainingCenterDatabase || tcx; + + const activities = root.Activities?.Activity + ? ( + Array.isArray(root.Activities.Activity) + ? root.Activities.Activity + : [root.Activities.Activity] + ) + : []; + + const activity = activities[0] || {}; + const sport = activity.$?.Sport || null; + + const laps = activity.Lap + ? ( + Array.isArray(activity.Lap) + ? activity.Lap + : [activity.Lap] + ) + : []; + + const tracks = []; + + for (const lap of laps) { + const track = lap.Track + ? ( + Array.isArray(lap.Track) + ? lap.Track + : [lap.Track] + ) + : []; + + tracks.push(...track); + } + + const points = []; + + for (const t of tracks) { + const tp = t.Trackpoint + ? ( + Array.isArray(t.Trackpoint) + ? t.Trackpoint + : [t.Trackpoint] + ) + : []; + + points.push(...tp); + } + + let minLat = null; + let maxLat = null; + let minLon = null; + let maxLon = null; + let minElev = null; + let maxElev = null; + + let startTime = null; + let endTime = null; + + let totalDistance = 0; + let totalCalories = 0; + + let avgHr = null; + let maxHr = null; + + for (const lap of laps) { + if (!startTime && lap.StartTime) { + startTime = lap.StartTime; + } + + if (lap.TotalTimeSeconds && lap.StartTime) { + const lapEnd = new Date(lap.StartTime); + lapEnd.setSeconds(lapEnd.getSeconds() + Number(lap.TotalTimeSeconds)); + endTime = lapEnd.toISOString(); + } + + if (lap.DistanceMeters) { + totalDistance += Number(lap.DistanceMeters); + } + + if (lap.Calories) { + totalCalories += Number(lap.Calories); + } + + const lapAvgHr = lap.AverageHeartRateBpm?.Value + ? Number(lap.AverageHeartRateBpm.Value) + : null; + + const lapMaxHr = lap.MaximumHeartRateBpm?.Value + ? Number(lap.MaximumHeartRateBpm.Value) + : null; + + if (Number.isFinite(lapAvgHr)) { + avgHr = avgHr == null ? lapAvgHr : (avgHr + lapAvgHr) / 2; + } + + if (Number.isFinite(lapMaxHr)) { + maxHr = maxHr == null ? lapMaxHr : Math.max(maxHr, lapMaxHr); + } + } + + for (const p of points) { + const pos = p.Position || {}; + + const lat = pos.LatitudeDegrees != null + ? Number(pos.LatitudeDegrees) + : null; + + const lon = pos.LongitudeDegrees != null + ? Number(pos.LongitudeDegrees) + : null; + + const ele = p.AltitudeMeters != null + ? Number(p.AltitudeMeters) + : null; + + const time = p.Time || null; + + if (Number.isFinite(lat) && Number.isFinite(lon)) { + minLat = minLat == null ? lat : Math.min(minLat, lat); + maxLat = maxLat == null ? lat : Math.max(maxLat, lat); + minLon = minLon == null ? lon : Math.min(minLon, lon); + maxLon = maxLon == null ? lon : Math.max(maxLon, lon); + } + + if (Number.isFinite(ele)) { + minElev = minElev == null ? ele : Math.min(minElev, ele); + maxElev = maxElev == null ? ele : Math.max(maxElev, ele); + } + + if (time) { + if (!startTime) startTime = time; + endTime = time; + } + } + + const start_time_millis = toMillis(startTime); + const end_time_millis = toMillis(endTime); + + const duration_millis = + start_time_millis && end_time_millis + ? end_time_millis - start_time_millis + : null; + + const centerLat = + minLat != null && maxLat != null + ? (minLat + maxLat) / 2 + : null; + + const centerLon = + minLon != null && maxLon != null + ? (minLon + maxLon) / 2 + : null; + + const trackPreview = buildTrackPreviewFromTcxPoints(points, 300); + + return { + format: 'TCX', + + display_name: activity.Notes || null, + route_name: activity.Notes || null, + title: activity.Notes || null, + description: null, + notes: activity.Notes || null, + + sport, + activity_type: null, + device_manufacturer: root.Author?.Name || null, + device_model: root.Author?.Build?.Version?.VersionMajor || null, + source_app: root.Author?.Name || null, + + start_time_millis, + end_time_millis, + duration_millis, + + distance_meters: totalDistance || null, + elevation_gain_meters: null, + elevation_loss_meters: null, + + min_elevation_meters: minElev, + max_elevation_meters: maxElev, + + file_distance_meters: totalDistance || null, + file_elevation_gain_meters: null, + + computed_distance_meters: totalDistance || null, + computed_elevation_gain_meters: null, + + average_speed_mps: null, + max_speed_mps: null, + average_pace_sec_per_km: null, + + average_heart_rate: avgHr, + max_heart_rate: maxHr, + average_cadence: null, + max_cadence: null, + + calories: totalCalories || null, + power_avg: null, + power_max: null, + + point_count: points.length, + segment_count: tracks.length, + lap_count: laps.length, + + min_latitude: minLat, + max_latitude: maxLat, + min_longitude: minLon, + max_longitude: maxLon, + center_latitude: centerLat, + center_longitude: centerLon, + + // Preview ridotta del percorso per i thumb della gallery + track_preview: trackPreview, + + tags: [], + + diagnostics: { + source: 'TCX', + laps: laps.length, + tracks: tracks.length, + points: points.length, + track_preview_points: trackPreview.length + } + }; +} + +module.exports = parseTcx; diff --git a/api_v1/scanner/parsers/parse_fit.py b/api_v1/scanner/parsers/parse_fit.py new file mode 100644 index 0000000..5f06bb3 --- /dev/null +++ b/api_v1/scanner/parsers/parse_fit.py @@ -0,0 +1,38 @@ +# parse_fit.py +import fitdecode +import json +import sys +from datetime import datetime + +path = sys.argv[1] + +def to_json_safe(value): + # Converte datetime → stringa ISO + if isinstance(value, datetime): + return value.isoformat() + + # Converte liste/tuple ricorsivamente + if isinstance(value, (list, tuple)): + return [to_json_safe(v) for v in value] + + # Converte dict ricorsivamente + if isinstance(value, dict): + return {k: to_json_safe(v) for k, v in value.items()} + + # Tutto il resto viene restituito così com'è + return value + +frames = [] + +with fitdecode.FitReader(path) as fit: + for frame in fit: + if isinstance(frame, fitdecode.FitDataMessage): + msg = { + "name": frame.name, + "fields": {} + } + for field in frame.fields: + msg["fields"][field.name] = to_json_safe(field.value) + frames.append(msg) + +print(json.dumps(frames)) diff --git a/api_v1/scanner/photoIngestQueue.js b/api_v1/scanner/photoIngestQueue.js new file mode 100644 index 0000000..b5b44aa --- /dev/null +++ b/api_v1/scanner/photoIngestQueue.js @@ -0,0 +1,348 @@ +'use strict'; + +const crypto = require('crypto'); + +const TABLE = 'photo_ingest_jobs'; + +const SQLITE_BUSY_RETRY_DELAYS_MS = [ + 25, + 50, + 100, + 200, + 400, + 800, + 1600, +]; + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function isSqliteBusy(error) { + if (!error) return false; + + if ( + error.code === 'SQLITE_BUSY' || + error.code === 'SQLITE_LOCKED' + ) { + return true; + } + + const message = String(error.message || error).toLowerCase(); + + return ( + message.includes('database is locked') || + message.includes('database table is locked') + ); +} + +async function withSqliteBusyRetry(label, operation) { + let retry = 0; + + while (true) { + try { + return await operation(); + } catch (error) { + if ( + !isSqliteBusy(error) || + retry >= SQLITE_BUSY_RETRY_DELAYS_MS.length + ) { + throw error; + } + + const delay = SQLITE_BUSY_RETRY_DELAYS_MS[retry]; + retry += 1; + + console.warn( + `[PhotoIngestQueue] SQLITE_BUSY ${label}; ` + + `retry=${retry}/${SQLITE_BUSY_RETRY_DELAYS_MS.length} ` + + `delay=${delay}ms` + ); + + await sleep(delay); + } + } +} + +function makeJobId(user, cartella, relPath) { + return crypto + .createHash('sha256') + .update(JSON.stringify([user, cartella, relPath])) + .digest('hex'); +} + +async function ensurePhotoIngestQueue(db) { + if (!(await db.schema.hasTable(TABLE))) { + await db.schema.createTable(TABLE, (t) => { + t.string('id').primary(); + t.string('user').notNullable(); + t.string('cartella').notNullable(); + t.string('rel_path').notNullable(); + t.integer('source_mtime_ms').notNullable(); + t.integer('source_size_bytes').notNullable(); + t.string('status').notNullable().defaultTo('pending'); + t.integer('attempts').notNullable().defaultTo(0); + t.integer('rescan_requested').notNullable().defaultTo(0); + t.text('last_error').nullable(); + t.datetime('started_at').nullable(); + t.datetime('completed_at').nullable(); + t.datetime('created_at').notNullable().defaultTo(db.fn.now()); + t.datetime('updated_at').notNullable().defaultTo(db.fn.now()); + t.index(['status', 'created_at'], 'idx_photo_ingest_status_created'); + t.index(['user', 'cartella'], 'idx_photo_ingest_folder'); + }); + } + + if (!(await db.schema.hasColumn(TABLE, 'rescan_requested'))) { + await db.schema.alterTable(TABLE, (t) => { + t.integer('rescan_requested').notNullable().defaultTo(0); + }); + } +} + +async function enqueuePhotoIngestJob(db, input) { + const { user, cartella, relPath, mtimeMs, sizeBytes } = input; + + if ( + typeof user !== 'string' || !user || + typeof cartella !== 'string' || !cartella || + typeof relPath !== 'string' || !relPath || + !Number.isFinite(mtimeMs) || mtimeMs < 0 || + !Number.isFinite(sizeBytes) || sizeBytes < 0 + ) { + throw new TypeError('Dati non validi per accodare la foto'); + } + + await ensurePhotoIngestQueue(db); + + const id = makeJobId(user, cartella, relPath); + const mtime = Math.trunc(mtimeMs); + const size = Math.trunc(sizeBytes); + + return withSqliteBusyRetry('enqueue', () => + db.transaction(async (trx) => { + const existing = await trx(TABLE).where({ id }).first(); + + if (existing) { + const unchanged = + Number(existing.source_mtime_ms) === mtime && + Number(existing.source_size_bytes) === size; + + if (unchanged) { + // Un errore non viene riavviato da eventi duplicati. + return { id, status: existing.status, unchanged: true }; + } + + if (existing.status === 'processing') { + // Conserva la versione più recente richiesta. + // Il worker finirà il lavoro corrente e poi riaccoderà. + await trx(TABLE).where({ id, status: 'processing' }).update({ + source_mtime_ms: mtime, + source_size_bytes: size, + rescan_requested: 1, + updated_at: trx.fn.now(), + }); + + return { + id, + status: 'processing', + changedWhileProcessing: true, + }; + } + + await trx(TABLE).where({ id }).update({ + source_mtime_ms: mtime, + source_size_bytes: size, + status: 'pending', + attempts: 0, + rescan_requested: 0, + last_error: null, + started_at: null, + completed_at: null, + updated_at: trx.fn.now(), + }); + + return { id, status: 'pending', updated: true }; + } + + await trx(TABLE).insert({ + id, + user, + cartella, + rel_path: relPath, + source_mtime_ms: mtime, + source_size_bytes: size, + status: 'pending', + attempts: 0, + rescan_requested: 0, + created_at: trx.fn.now(), + updated_at: trx.fn.now(), + }); + + return { id, status: 'pending', inserted: true }; + }) + ); +} + +async function claimNextPhotoIngestJob(db) { + await ensurePhotoIngestQueue(db); + + return withSqliteBusyRetry('claim', () => + db.transaction(async (trx) => { + const job = await trx(TABLE) + .where({ status: 'pending' }) + .orderBy('created_at', 'asc') + .first(); + + if (!job) return null; + + const changed = await trx(TABLE) + .where({ id: job.id, status: 'pending' }) + .update({ + status: 'processing', + attempts: trx.raw('attempts + 1'), + rescan_requested: 0, + started_at: trx.fn.now(), + completed_at: null, + last_error: null, + updated_at: trx.fn.now(), + }); + + if (!changed) return null; + + return { + ...job, + status: 'processing', + attempts: Number(job.attempts || 0) + 1, + rescan_requested: 0, + }; + }) + ); +} + +async function completePhotoIngestJob(db, id) { + return withSqliteBusyRetry('complete', () => + db.transaction(async (trx) => { + const job = await trx(TABLE) + .where({ id, status: 'processing' }) + .first(); + + if (!job) return { updated: false }; + + if (Number(job.rescan_requested) === 1) { + await trx(TABLE).where({ id, status: 'processing' }).update({ + status: 'pending', + rescan_requested: 0, + started_at: null, + completed_at: null, + last_error: null, + updated_at: trx.fn.now(), + }); + + return { updated: true, status: 'pending', requeued: true }; + } + + await trx(TABLE).where({ id, status: 'processing' }).update({ + status: 'done', + completed_at: trx.fn.now(), + updated_at: trx.fn.now(), + last_error: null, + }); + + return { updated: true, status: 'done', requeued: false }; + }) + ); +} + +async function failPhotoIngestJob(db, id, error) { + const message = String(error?.stack || error?.message || error) + .slice(0, 12000); + + return withSqliteBusyRetry('fail', () => + db.transaction(async (trx) => { + const job = await trx(TABLE) + .where({ id, status: 'processing' }) + .first(); + + if (!job) return { updated: false }; + + if (Number(job.rescan_requested) === 1) { + await trx(TABLE).where({ id, status: 'processing' }).update({ + status: 'pending', + rescan_requested: 0, + started_at: null, + completed_at: null, + last_error: message, + updated_at: trx.fn.now(), + }); + + return { updated: true, status: 'pending', requeued: true }; + } + + await trx(TABLE).where({ id, status: 'processing' }).update({ + status: 'error', + last_error: message, + completed_at: trx.fn.now(), + updated_at: trx.fn.now(), + }); + + return { updated: true, status: 'error', requeued: false }; + }) + ); +} + +async function resetStalePhotoIngestJobs(db, staleMinutes = 30) { + await ensurePhotoIngestQueue(db); + + const cutoff = new Date(Date.now() - staleMinutes * 60 * 1000) + .toISOString() + .replace('T', ' ') + .replace(/\.\d{3}Z$/, ''); + + return db(TABLE) + .where({ status: 'processing' }) + .whereNotNull('started_at') + .where('started_at', '<', cutoff) + .update({ + status: 'pending', + rescan_requested: 0, + started_at: null, + completed_at: null, + last_error: 'Recuperato dopo interruzione del worker', + updated_at: db.fn.now(), + }); +} + +async function getPhotoIngestQueueCounts(db) { + await ensurePhotoIngestQueue(db); + + const rows = await db(TABLE) + .select('status') + .count({ total: '*' }) + .groupBy('status'); + + const counts = { + pending: 0, + processing: 0, + done: 0, + error: 0, + }; + + for (const row of rows) { + if (Object.hasOwn(counts, row.status)) { + counts[row.status] = Number(row.total); + } + } + + return counts; +} + +module.exports = { + ensurePhotoIngestQueue, + enqueuePhotoIngestJob, + claimNextPhotoIngestJob, + completePhotoIngestJob, + failPhotoIngestJob, + resetStalePhotoIngestJobs, + getPhotoIngestQueueCounts, +}; diff --git a/api_v1/scanner/photoIngestWorker.js b/api_v1/scanner/photoIngestWorker.js new file mode 100644 index 0000000..2a7997b --- /dev/null +++ b/api_v1/scanner/photoIngestWorker.js @@ -0,0 +1,263 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs/promises'); + +const { WEB_ROOT, SUPPORTED_EXTS } = require('../config'); +const scanPhotoSingle = require('./scanPhotoSingle'); +const { + enqueueServerEvent, +} = require('../../db/serverEventOutbox'); +const { sha256 } = require('./utils'); + +const { + ensurePhotoIngestQueue, + claimNextPhotoIngestJob, + enqueuePhotoIngestJob, + completePhotoIngestJob, + failPhotoIngestJob, + resetStalePhotoIngestJobs, +} = require('./photoIngestQueue'); + +function sameVersion(stat, job) { + return ( + Math.trunc(stat.mtimeMs) === Math.trunc(Number(job.source_mtime_ms)) && + stat.size === Number(job.source_size_bytes) + ); +} + +function isInside(parent, child) { + const relative = path.relative(parent, child); + return relative === '' || + (!relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative)); +} + +async function resolveJobFile(job) { + for (const value of [job.user, job.cartella, job.rel_path]) { + if (typeof value !== 'string' || !value.trim()) { + throw new Error('Percorso del job non valido'); + } + } + + if ( + path.isAbsolute(job.rel_path) || + job.rel_path.includes('\\') || + job.rel_path.split('/').some(part => + !part || part === '.' || part === '..' + ) + ) { + throw new Error('Percorso relativo non sicuro'); + } + + if ( + job.user === '.' || job.user === '..' || + job.cartella === '.' || job.cartella === '..' || + job.user.includes('/') || job.user.includes('\\') || + job.cartella.includes('/') || job.cartella.includes('\\') + ) { + throw new Error('Utente o cartella non validi'); + } + + const photosRoot = path.resolve( + __dirname, '..', '..', WEB_ROOT, 'photos' + ); + + const originalRoot = path.resolve( + photosRoot, job.user, 'original', job.cartella + ); + + const absPath = path.resolve(originalRoot, ...job.rel_path.split('/')); + + if (!isInside(originalRoot, absPath)) { + throw new Error('Il file esce dalla cartella originale prevista'); + } + + const realRoot = await fs.realpath(originalRoot); + const realFile = await fs.realpath(absPath); + + if (!isInside(realRoot, realFile)) { + throw new Error('Il file risolve fuori dalla cartella originale'); + } + + const ext = path.extname(absPath).toLowerCase(); + + if (!SUPPORTED_EXTS.has(ext)) { + throw new Error(`Estensione non supportata: ${ext}`); + } + + const stat = await fs.stat(realFile); + + if (!stat.isFile()) { + throw new Error('Il percorso non è un file regolare'); + } + + return { + id: sha256(`${job.user}/${job.cartella}/${job.rel_path}`), + user: job.user, + cartella: job.cartella, + name: path.basename(absPath), + relPath: job.rel_path, + absPath: realFile, + ext, + stat, + path: `/photos/${job.user}/original/${job.cartella}/${job.rel_path}`, + }; +} + +async function runOneJob(db) { + await ensurePhotoIngestQueue(db); + + const job = await claimNextPhotoIngestJob(db); + + if (!job) { + return { processed: false }; + } + + console.log(`[PhotoIngest] Avvio job ${job.id}: ${job.rel_path}`); + + try { + const file = await resolveJobFile(job); + + // Se la versione è già cambiata, aggiorna il job mentre è + // processing: completePhotoIngestJob lo riporterà a pending. + if (!sameVersion(file.stat, job)) { + await enqueuePhotoIngestJob(db, { + user: job.user, + cartella: job.cartella, + relPath: job.rel_path, + mtimeMs: file.stat.mtimeMs, + sizeBytes: file.stat.size, + }); + + const result = await completePhotoIngestJob(db, job.id); + return { + processed: true, + status: result.status, + requeued: Boolean(result.requeued), + }; + } + + const previous = await db('photos') + .select('id', 'size_bytes', 'mtimeMs', 'fast_hash', 'path') + .where({ id: file.id }) + .first(); + + const scannedPhoto = await scanPhotoSingle( + db, + job.user, + job.cartella, + file, + [], + '', + previous || null + ); + + // Se il file è scomparso, non eliminare il record fotografico. + // Il job va in errore e l'evento di cancellazione resta responsabilità + // del watcher. + const statAfter = await fs.stat(file.absPath); + + // Se è cambiato durante la lavorazione, aggiorna la versione in coda + // prima di completare il job: rescan_requested garantisce il retry. + if ( + Math.trunc(statAfter.mtimeMs) !== Math.trunc(file.stat.mtimeMs) || + statAfter.size !== file.stat.size + ) { + await enqueuePhotoIngestJob(db, { + user: job.user, + cartella: job.cartella, + relPath: job.rel_path, + mtimeMs: statAfter.mtimeMs, + sizeBytes: statAfter.size, + }); + } + + const result = await completePhotoIngestJob(db, job.id); + + console.log( + `[PhotoIngest] Job ${job.id} completato; stato: ${result.status}` + ); + + /* + * Notifica una foto realmente nuova soltanto dopo che il + * job e' stato completato definitivamente. + * + * IMPORTANTE: + * qui NON viene importato ws-server. + * Il daemon scrive soltanto nella outbox SQLite. + */ + if ( + !previous && + !result.requeued && + result.status === 'done' && + scannedPhoto && + scannedPhoto.id + ) { + await enqueueServerEvent(db, { + channel: 'photo', + user: job.user, + payload: { + type: 'added', + id: scannedPhoto.id, + }, + }); + + console.log( + `[PhotoIngest] Evento gallery accodato photo=${scannedPhoto.id}` + ); + } + + return { + processed: true, + status: result.status, + requeued: Boolean(result.requeued), + }; + } catch (err) { + console.error(`[PhotoIngest] Errore job ${job.id}:`, err.message); + + try { + await failPhotoIngestJob(db, job.id, err.message); + } catch (queueErr) { + console.error( + '[PhotoIngest] Errore aggiornando la coda:', + queueErr.message + ); + } + + return { processed: true, error: err.message }; + } +} + +async function runWorker(db, options = {}) { + const pollMs = Math.max(250, Number(options.pollMs) || 2000); + const staleMinutes = Math.max( + 1, Number(options.staleMinutes) || 30 + ); + + await ensurePhotoIngestQueue(db); + await resetStalePhotoIngestJobs(db, staleMinutes); + + console.log(`[PhotoIngest] Worker avviato; polling ${pollMs} ms`); + + while (true) { + try { + const result = await runOneJob(db); + + if (!result.processed) { + await new Promise(resolve => setTimeout(resolve, pollMs)); + } + } catch (err) { + console.error('[PhotoIngest] Errore worker:', err); + await new Promise(resolve => setTimeout(resolve, pollMs)); + } + } +} + +module.exports = { + runOneJob, + runWorker, + resolveJobFile, + sameVersion, +}; diff --git a/api_v1/scanner/photoScanProgress.js b/api_v1/scanner/photoScanProgress.js new file mode 100644 index 0000000..6aef0ca --- /dev/null +++ b/api_v1/scanner/photoScanProgress.js @@ -0,0 +1,91 @@ +'use strict'; + +const photoIngestQueue = require('./photoIngestQueue'); + +let epoch = null; + +async function beginGlobalPhotoScan(db) { + if (!epoch || !epoch.active) { + const created = { + active: true, + baselineDone: 0, + enumerationsInProgress: 0, + initPromise: null, + }; + + epoch = created; + created.initPromise = photoIngestQueue + .getPhotoIngestQueueCounts(db) + .then(counts => { + created.baselineDone = Number(counts.done || 0); + }) + .catch(err => { + if (epoch === created) created.active = false; + throw err; + }); + } + + const current = epoch; + current.enumerationsInProgress++; + try { + await current.initPromise; + } catch (err) { + current.enumerationsInProgress = Math.max( + 0, current.enumerationsInProgress - 1 + ); + throw err; + } +} + +function finishGlobalPhotoScan() { + if (epoch) { + epoch.enumerationsInProgress = Math.max( + 0, epoch.enumerationsInProgress - 1 + ); + } +} + +async function getGlobalPhotoScanStatus(db) { + const counts = await photoIngestQueue.getPhotoIngestQueueCounts(db); + const pending = Number(counts.pending || 0); + const processing = Number(counts.processing || 0); + const done = Number(counts.done || 0); + const errors = Number(counts.error || 0); + const remaining = pending + processing; + const totalCurrent = pending + processing + done + errors; + + let current; + let total; + let percent; + let baselineDone = done; + + if (epoch && epoch.active) { + baselineDone = epoch.baselineDone; + total = Math.max(0, totalCurrent - baselineDone); + current = Math.max(0, (done - baselineDone) + errors); + percent = total <= 0 + ? 100 + : Math.max(0, Math.min(100, Math.round(current / total * 100))); + + // Chiude il ciclo solo quando tutte le richieste di scansione + // hanno terminato l'accodamento e la coda è vuota. + if (epoch.enumerationsInProgress === 0 && remaining === 0) { + epoch.active = false; + } + } else { + total = totalCurrent; + current = totalCurrent; + percent = 100; + } + + return { + pending, processing, done, errors, remaining, + current, total, percent, baselineDone, + }; +} + +module.exports = { + beginGlobalPhotoScan, + finishGlobalPhotoScan, + getGlobalPhotoScanStatus, +}; diff --git a/api_v1/scanner/postWithAuth.js b/api_v1/scanner/postWithAuth.js new file mode 100644 index 0000000..1258e6d --- /dev/null +++ b/api_v1/scanner/postWithAuth.js @@ -0,0 +1,24 @@ +// scanner/postWithAuth.js +// Versione locale: niente HTTP, niente token, solo DB + +module.exports = function createPostToDB(db) { + + /** + * Inserisce o aggiorna un record nel DB SQLite + * (sostituisce completamente axios.post) + */ + async function postToDB(record) { + if (!record || !record.id) { + throw new Error("Record non valido"); + } + + await db('photos') + .insert(record) + .onConflict('id') + .merge(); + + return true; + } + + return postToDB; +}; diff --git a/api_v1/scanner/processActivityFile.js b/api_v1/scanner/processActivityFile.js new file mode 100644 index 0000000..eb37181 --- /dev/null +++ b/api_v1/scanner/processActivityFile.js @@ -0,0 +1,188 @@ +// api_v1/scanner/processActivityFile.js +const path = require('path'); +const fsp = require('fs/promises'); +const { sha256 } = require('./utils'); +const { log } = require('./logger'); +const parseActivityFile = require('./parseActivityFile'); // 🔥 parser FIT/GPX/TCX +const { WEB_ROOT, PATH_FULL } = require('../config'); + +// --------------------------------------------------------- +// processActivityFile → equivalente di processFile per le attività +// --------------------------------------------------------- +async function processActivityFile(userName, cartella, fileRelPath, absPath, ext, st) { + + // 🔥 PARSING FIT/GPX/TCX + const parsed = await parseActivityFile(absPath); + + // parsed contiene: + // { + // file_name, + // file_extension, + // format, + // display_name, + // route_name, + // title, + // description, + // notes, + // sport, + // activity_type, + // device_manufacturer, + // device_model, + // source_app, + // start_time_millis, + // end_time_millis, + // duration_millis, + // distance_meters, + // elevation_gain_meters, + // elevation_loss_meters, + // min_elevation_meters, + // max_elevation_meters, + // file_distance_meters, + // file_elevation_gain_meters, + // computed_distance_meters, + // computed_elevation_gain_meters, + // average_speed_mps, + // max_speed_mps, + // average_pace_sec_per_km, + // average_heart_rate, + // max_heart_rate, + // average_cadence, + // max_cadence, + // calories, + // power_avg, + // power_max, + // point_count, + // segment_count, + // lap_count, + // min_latitude, + // max_latitude, + // min_longitude, + // max_longitude, + // center_latitude, + // center_longitude, + // tags, + // diagnostics + // } + + // --------------------------------------------------------- + // GPS + // + // I dati altimetrici dell'attività provengono direttamente + // dal file FIT, GPX o TCX. Il reverse geocoding viene + // completato successivamente dal geocode worker. + // --------------------------------------------------------- + let gps = null; + + if ( + parsed.center_latitude != null + && parsed.center_longitude != null + ) { + gps = { + lat: parsed.center_latitude, + lng: parsed.center_longitude, + alt: parsed.min_elevation_meters ?? null, + }; + } + + const location = null; + + // --------------------------------------------------------- + // PATH FULL / RELATIVI + // --------------------------------------------------------- + const relPath = fileRelPath; + + const fullPath = PATH_FULL + ? path.posix.join('/activities', userName, cartella, fileRelPath) + : relPath; + + // --------------------------------------------------------- + // ID deterministico + // --------------------------------------------------------- + const id = sha256(`${userName}/${cartella}/${fileRelPath}`); + + // --------------------------------------------------------- + // COSTRUZIONE META (identico a normalizeActivity) + // --------------------------------------------------------- + return { + id, + user: userName, + cartella, + taken_at: new Date(parsed.start_time_millis).toISOString(), + file_name: parsed.file_name, + file_extension: parsed.file_extension, + format: parsed.format, + display_name: parsed.display_name, + route_name: parsed.route_name, + title: parsed.title, + description: parsed.description, + notes: parsed.notes, + sport: parsed.sport, + activity_type: parsed.activity_type, + device_manufacturer: parsed.device_manufacturer, + device_model: parsed.device_model, + source_app: parsed.source_app, + + start_time_millis: parsed.start_time_millis, + end_time_millis: parsed.end_time_millis, + duration_millis: parsed.duration_millis, + + distance_meters: parsed.distance_meters, + elevation_gain_meters: parsed.elevation_gain_meters, + elevation_loss_meters: parsed.elevation_loss_meters, + + min_elevation_meters: parsed.min_elevation_meters, + max_elevation_meters: parsed.max_elevation_meters, + + file_distance_meters: parsed.file_distance_meters, + file_elevation_gain_meters: parsed.file_elevation_gain_meters, + + computed_distance_meters: parsed.computed_distance_meters, + computed_elevation_gain_meters: parsed.computed_elevation_gain_meters, + + average_speed_mps: parsed.average_speed_mps, + max_speed_mps: parsed.max_speed_mps, + average_pace_sec_per_km: parsed.average_pace_sec_per_km, + + average_heart_rate: parsed.average_heart_rate, + max_heart_rate: parsed.max_heart_rate, + average_cadence: parsed.average_cadence, + max_cadence: parsed.max_cadence, + + calories: parsed.calories, + power_avg: parsed.power_avg, + power_max: parsed.power_max, + + point_count: parsed.point_count, + segment_count: parsed.segment_count, + lap_count: parsed.lap_count, + + min_latitude: parsed.min_latitude, + max_latitude: parsed.max_latitude, + min_longitude: parsed.min_longitude, + max_longitude: parsed.max_longitude, + center_latitude: parsed.center_latitude, + center_longitude: parsed.center_longitude, + + size_bytes: st.size, + last_modified_millis: st.mtimeMs, + date_added_millis: Date.now(), + date_modified_millis: Date.now(), + last_scan_millis: Date.now(), + + is_favorite: false, + is_hidden: false, + rating: 0, + + tags: parsed.tags || [], + track_preview: parsed.track_preview || [], + scan_status: "ok", + scan_error: null, + diagnostics: parsed.diagnostics || null, + + gps, + location, + path: fullPath + }; +} + +module.exports = processActivityFile; diff --git a/api_v1/scanner/processFile.js b/api_v1/scanner/processFile.js new file mode 100644 index 0000000..8558c1b --- /dev/null +++ b/api_v1/scanner/processFile.js @@ -0,0 +1,224 @@ +const path = require("path"); +const fsp = require("fs/promises"); +const ExifReader = require("exifreader"); +const sharp = require("sharp"); +const { sha256, inferMimeFromExt, parseExifDateUtc } = require("./utils"); +const { extractGpsFromExif, extractGpsWithExiftool } = require("./gps"); +const { createVideoThumbnail, createThumbnails } = require("./thumbs"); +const { probeVideo } = require("./video"); +const { WEB_ROOT, PATH_FULL } = require("../config"); + +async function processFile(userName, cartella, fileRelPath, absPath, ext, st) { + const isVideo = [".mp4", ".mov", ".m4v"].includes(ext); + + const thumbBase = path.join( + WEB_ROOT, + "photos", + userName, + "thumbs", + cartella, + path.dirname(fileRelPath), + ); + + await fsp.mkdir(thumbBase, { recursive: true }); + + // --- Nome file + estensione (dal path ASSOLUTO, sempre corretto) --- + const parsed = path.parse(absPath); + const baseName = parsed.name; // IMG_0249 + const extName = parsed.ext; // .JPG + + const absThumbMin = path.join(thumbBase, `${baseName}_min.jpg`); + const absThumbAvg = path.join(thumbBase, `${baseName}_avg.jpg`); +const thumbStart = process.hrtime.bigint(); + if (isVideo) { + await createVideoThumbnail(absPath, absThumbMin, absThumbAvg); + } else { + await createThumbnails(absPath, absThumbMin, absThumbAvg); + } +const thumbMs = + Number(process.hrtime.bigint() - thumbStart) / 1e6; + +console.log( + `⏱️ [PROFILE processFile] ${path.basename(absPath)} thumbnails=${thumbMs.toFixed(2)} ms` +); +const exifStart = process.hrtime.bigint(); + + // --- EXIF --- + let tags = {}; + try { + tags = await ExifReader.load(absPath, { expanded: true }); + } catch {} + + let timeRaw = tags?.exif?.DateTimeOriginal?.value?.[0] || null; + +const exifMs = + Number(process.hrtime.bigint() - exifStart) / 1e6; + +console.log( + `⏱️ [PROFILE processFile] ${path.basename(absPath)} exif=${exifMs.toFixed(2)} ms` +); + + let takenAtIso = parseExifDateUtc(timeRaw); + + // Fallback per i video + if (isVideo) { + const info = await probeVideo(absPath); + + // Cerca la data nei punti standard + const creationFormat = info.format?.tags?.creation_time || null; + const creationStream0 = info.streams?.[0]?.tags?.creation_time || null; + const creationStream1 = info.streams?.[1]?.tags?.creation_time || null; + + // Scegli la prima disponibile + const videoDate = + creationFormat || creationStream0 || creationStream1 || null; + + if (videoDate) { + // NON convertire, NON usare new Date() + takenAtIso = videoDate; + timeRaw = videoDate; + } else { + // Fallback finale: mtime del file + const fallback = new Date(st.mtimeMs).toISOString(); + takenAtIso = fallback; + timeRaw = fallback; + } + } + + // --- GPS --- + let gps = null; + + if (isVideo) { + // i video usano exiftool + gps = await extractGpsWithExiftool(absPath); + } else { + // le foto usano exifreader + gps = extractGpsFromExif(tags); + } + + // L'altitudine EXIF viene mantenuta. + // Se assente, sarà recuperata dal geocode worker. + + // --- DIMENSIONI & ROTAZIONE --- + let width = null, + height = null, + duration = null, + duration_ms = null, + rotation = 0; + + if (isVideo) { + const info = await probeVideo(absPath); + const stream = info.streams?.find((s) => s.width && s.height); + + if (stream) { + width = stream.width; + height = stream.height; + + rotation = 0; + + if (stream?.tags?.rotate) { + rotation = Number(stream.tags.rotate); + } + + const sdl = stream?.side_data_list; + if (sdl && Array.isArray(sdl)) { + const rotEntry = sdl.find((d) => d.rotation !== undefined); + if (rotEntry) rotation = Number(rotEntry.rotation); + + const matrixEntry = sdl.find( + (d) => typeof d.displaymatrix === "string", + ); + if (matrixEntry) { + const match = matrixEntry.displaymatrix.match( + /rotation of ([\-0-9]+) degrees/i, + ); + if (match) rotation = Number(match[1]); + } + } + + rotation = ((rotation % 360) + 360) % 360; + } + + duration = info.format?.duration || null; + duration_ms = duration ? Math.round(duration * 1000) : null; + } else { +const metadataStart = process.hrtime.bigint(); + + try { + const meta = await sharp(absPath).metadata(); + width = meta.width || null; + height = meta.height || null; + } catch {} + +const metadataMs = + Number(process.hrtime.bigint() - metadataStart) / 1e6; + +console.log( + `⏱️ [PROFILE processFile] ${path.basename(absPath)} metadata=${metadataMs.toFixed(2)} ms` +); + + try { + const raw = + tags?.exif?.Orientation?.value ?? + tags?.image?.Orientation?.value ?? + tags?.ifd0?.Orientation?.value ?? + null; + + const val = Array.isArray(raw) ? raw[0] : raw; + + const map = { 1: 0, 3: 180, 6: 90, 8: 270 }; + rotation = map[val] ?? 0; + } catch {} + } + + const mime_type = inferMimeFromExt(ext); + const id = sha256(`${userName}/${cartella}/${fileRelPath}`); + + // Reverse geocoding eseguito in modo asincrono. + const location = null; + + // + // --- GESTIONE PATH FULL / RELATIVI --- + // + + const relPath = fileRelPath; + const relThub1 = fileRelPath.replace(/\.[^.]+$/, "_min.jpg"); + const relThub2 = fileRelPath.replace(/\.[^.]+$/, "_avg.jpg"); +const fullPath = PATH_FULL + ? path.posix.join("/photos", userName, cartella, fileRelPath) + : relPath; + + const fullThub1 = PATH_FULL + ? path.posix.join("/photos", userName, "thumbs", cartella, relThub1) + : relThub1; + + const fullThub2 = PATH_FULL + ? path.posix.join("/photos", userName, "thumbs", cartella, relThub2) + : relThub2; + + const fullCast = ""; + + return { + id, + user: userName, + cartella, + name: baseName + extName, // 👈 SEMPRE CORRETTO + path: fullPath, + thub1: fullThub1, + thub2: fullThub2, + cast: fullCast, + gps, + data: timeRaw, + taken_at: takenAtIso, + mime_type, + width, + height, + rotation, + size_bytes: st.size, + mtimeMs: st.mtimeMs, + duration_ms: isVideo ? duration_ms : null, + location, + }; +} + +module.exports = processFile; diff --git a/api_v1/scanner/recordChange.js b/api_v1/scanner/recordChange.js new file mode 100644 index 0000000..d6853bf --- /dev/null +++ b/api_v1/scanner/recordChange.js @@ -0,0 +1,97 @@ +// api_v1/scanner/recordChange.js +const { log } = require('./logger'); +const wss = require('../../ws-server'); // IMPORTANTE + +async function recordChange(db, photo_id, user, change_type) { + const timestamp = new Date().toISOString(); + + // ----------------------------------------------------- + // DEDUPLICA SOLO PER "removed" + // ----------------------------------------------------- + if (change_type === 'removed') { + const already = await db('photo_changes') + .where({ photo_id, user, change_type: 'removed' }) + .first(); + + if (already) { + log(`📘 [CHANGES] removed già registrato → ${photo_id}, ma invio comunque WS`); + + // ----------------------------------------------------- + // 🔥 INVIA WS ANCHE SE GIÀ REGISTRATO + // ----------------------------------------------------- + + console.log("WS → invio evento (USER) [removed già registrato]"); + try { + wss.broadcastToUser(user, { + type: "removed", + id: photo_id + }); + console.log("WS → OK USER (removed già registrato)"); + } catch (e) { + console.error("WS → ERRORE USER (removed già registrato):", e); + } + + console.log("WS → invio evento (ADMIN) [removed già registrato]"); + try { + wss.broadcastToAdmins({ + type: "removed", + id: photo_id, + user + }); + console.log("WS → OK ADMIN (removed già registrato)"); + } catch (e) { + console.error("WS → ERRORE ADMIN (removed già registrato):", e); + } + + return; + } + } + + // ----------------------------------------------------- + // SALVA NEL DB (added o primo removed) + // ----------------------------------------------------- + await db("photo_changes").insert({ + photo_id, + user, + change_type, + timestamp + }); + + log(`📘 [CHANGES] ${change_type} → ${photo_id}`); + + // ----------------------------------------------------- + // LOG PRIMA DEL BROADCAST + // ----------------------------------------------------- + console.log("WS → invio evento:", { user, change_type, photo_id }); + + // ----------------------------------------------------- + // 🔥 BROADCAST A USER + // ----------------------------------------------------- + console.log("WS → invio evento (USER)"); + try { + wss.broadcastToUser(user, { + type: change_type, + id: photo_id + }); + console.log("WS → OK USER"); + } catch (e) { + console.error("WS → ERRORE USER:", e); + } + + // ----------------------------------------------------- + // 🔥 BROADCAST A ADMIN + // ----------------------------------------------------- + console.log("WS → invio evento (ADMIN)"); + try { + wss.broadcastToAdmins({ + type: change_type, + id: photo_id, + user + }); + console.log("WS → OK ADMIN"); + } catch (e) { + console.error("WS → ERRORE ADMIN:", e); + } +} + +module.exports = recordChange; \ No newline at end of file diff --git a/api_v1/scanner/scan.md b/api_v1/scanner/scan.md new file mode 100644 index 0000000..714f11e --- /dev/null +++ b/api_v1/scanner/scan.md @@ -0,0 +1,249 @@ +scanPhotosUser(userName, db) + │ + ├── individua: + │ photos//original/ + │ + ├── legge le entry dentro original/ + │ + ├── prende SOLO le directory + │ + ├── countFilesUser(userDir) + │ └── ricorsivamente guarda tutti i file + │ └── conta solo quelli con estensione in SUPPORTED_EXTS + │ + └── per ogni cartella: + scanPhoto(cartella, userName, db, ...) + │ + └── QUI viene probabilmente + deciso cosa fare col singolo file + +scanPhotosUser + │ + │ per ogni cartella + ▼ +scanPhoto() + │ + ▼ +scanPhotoCartella() + │ + └── QUI probabilmente avviene la decisione sul file + +Quindi scanPhoto(): +costruisce il percorso: +const absCartella = path.join(userDir, cartella); +passa la cartella a scanPhotoCartella(). +scanPhotoCartella() riempie newFiles. +solo dopo, scanPhoto() manda al server i file presenti in newFiles: + + +scanCartella.js non decide se la foto è già stata scansionata: decide solo quali file sono candidati alla scansione. + +cartella + ↓ +walk() ricorsivo + ↓ +è directory? + ├─ sì → entra dentro + └─ no + ↓ + estensione supportata? + ├─ no → SKIP + └─ sì + ↓ + crea ID deterministico + ↓ + stat() del file + ↓ + yield metadata + +Il valore di SUPPORTED_EXTS è quindi importante. Lo trovi in: +api_v1/config.js +La cosa ancora più interessante: l'ID +Per ogni file accettato crea: + +const id = sha256(`${userName}/${cartella}/${fileRelPath}`); + +Quindi l'ID non dipende dal contenuto della foto. +Dipende da: +user ++ +cartella ++ +percorso relativo + + +scanPhotoUser + ↓ +scanPhoto + ↓ +scanPhotoCartella + ↓ +scanCartella + ↓ +per ogni file con estensione supportata + ↓ +scanPhotoSingle(...) + ↓ +QUI viene probabilmente deciso: + ├── già presente → non fare certe operazioni + ├── nuovo → processa + └── modificato → eventualmente aggiorna + +Quindi non è scanPhotoCartella a decidere se processare o meno la foto. +È scanPhotoSingle. +4. Ma c'è un altro meccanismo importante: gli orfani +Dopo scanPhotoSingle trovi: +idsSet.delete(f.id); +Questo significa: +"Ho trovato fisicamente questo file durante la scansione, quindi il suo ID non è un orfano." +Alla fine: +for (const orphanId of idsSet) { + log(`🔴 Orfano: ${orphanId}`); + await deleteThumbsById(orphanId); + await deleteFromDB(orphanId, user); +} + +DB all'inizio + │ + ├── A + ├── B + └── C + ↓ +scansione filesystem + ↓ +trovo A → idsSet.delete(A) +trovo C → idsSet.delete(C) + ↓ +rimane B + ↓ +B non esiste più sul filesystem + ↓ +B = ORFANO + ↓ +cancella thumbnail + DB + + +Una foto viene presa in considerazione se: +si trova dentro una cartella sotto original; +è un file, non una directory; +la sua estensione è presente in SUPPORTED_EXTS. +Poi viene generato un ID: +sha256(`${userName}/${cartella}/${fileRelPath}`) +e il file viene passato a: +scanPhotoSingle(...) + + +La decisione è tutta qui +Quando trova un file, fa: +let prev = await db('photos') + .select('id', 'size_bytes', 'mtimeMs', 'fast_hash', 'path') + .where({ id }) + .first(); +Quindi cerca nel DB una foto con lo stesso ID. +L'ID, come abbiamo visto prima, è: +sha256(`${userName}/${cartella}/${fileRelPath}`) +Quindi normalmente identifica quel preciso percorso del file. +Poi calcola: +const fastHash = sha256(`${st.size}-${st.mtimeMs}`); +Quindi il contenuto non viene hashato: il "fast hash" dipende solo da: +dimensione file + data modifica +Le 3 situazioni +1. Foto nuova +Se: +if (!prev) +cioè non esiste nel DB un record con quell'ID, fa: +const meta = await processFile(...) +Questa è la parte che legge/elabora veramente la foto. +Poi la inserisce nel DB: +await db('photos').insert(...) +e soprattutto: +newFiles.push(meta); +Quindi una foto nuova viene processata e considerata newFile. +2. Foto già presente e invariata +Questa è la situazione che probabilmente ti interessava: +if (prev.fast_hash === fastHash) { +In questo caso NON chiama processFile(). +Fa solamente: +await db('photos') + .where({ id }) + .update({ + path: f.path, + size_bytes: st.size, + mtimeMs: st.mtimeMs, + fast_hash: fastHash, + updated_at: new Date().toISOString(), + }); + +return null; +Quindi: +Se l'ID esiste e size + mtimeMs sono uguali, la foto viene saltata. +Non viene riletta, non vengono rigenerate thumbnail, non viene ricalcolato GPS/location, ecc. +Questo è il vero "skip". +3. Foto modificata +Se esiste prev, ma: +prev.fast_hash !== fastHash +allora: +log(`${prefix} 🟠 Modificato: ${fileName}`); +e viene nuovamente chiamato: +const meta = await processFile(...) +Quindi la foto viene riprocessata completamente. +C'è però un caso speciale: path cambiato +Prima del controllo fast_hash c'è: +if (prev && prev.path !== f.path) { + log(`${prefix} 🟢 Nuovo/Modificato (path changed): ${fileName}`); + prev = null; +} +Questo significa: +se trovo lo stesso id ma il path nel DB è diverso da quello attuale, tratto il file come nuovo. +E quindi finisce qui: +if (!prev) { + ... + await processFile(...) + ... + await db('photos').insert(...) +} +Ma qui vedo una possibile anomalia +Se prev esiste già nel DB e fai: +prev = null; +poi fai: +await db('photos').insert({ + id: meta.id, + ... +}); +Se id è una chiave unica, potresti avere un errore di duplicate key invece di un vero aggiornamento. +Però per capire se questo caso è realmente possibile bisogna vedere come viene costruito f.path e come viene gestito il DB. + + + + TROVO FILE + │ + ▼ + estensione supportata? + │ │ + NO SI + │ │ + SKIP ▼ + calcolo ID + │ + ▼ + cerco ID nel DB + │ + ┌────────────┴────────────┐ + │ │ + non esiste esiste + │ │ + ▼ ▼ + processFile() path diverso? + │ │ │ + │ SI NO + │ │ │ + │ ▼ ▼ + │ prev=null confronto + │ fastHash + │ │ │ + │ UGUALE DIVERSO + │ │ │ + │ ▼ ▼ + │ SKIP processFile() + │ │ + └──────────────────────────────────────┘ diff --git a/api_v1/scanner/scanActivitiesUser.js b/api_v1/scanner/scanActivitiesUser.js new file mode 100644 index 0000000..9ba46a9 --- /dev/null +++ b/api_v1/scanner/scanActivitiesUser.js @@ -0,0 +1,116 @@ +// api_v1/scanner/scanActivitiesUser.js +const path = require('path'); +const fsp = require('fs/promises'); + +const scanActivity = require('./scanActivity'); +const { log } = require('./logger'); +const { WEB_ROOT, SUPPORTED_ACTIVITY_EXTS } = require('../config'); + +// --------------------------------------------------------- +// PRIMA PASSATA: conta TUTTI i file reali ricorsivamente +// --------------------------------------------------------- +async function countActivityFilesUser(rootDir) { + let count = 0; + + async function walk(dir) { + let entries = []; + + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const e of entries) { + const abs = path.join(dir, e.name); + + if (e.isDirectory()) { + await walk(abs); + } else { + const ext = path.extname(e.name).toLowerCase(); + + if (SUPPORTED_ACTIVITY_EXTS.has(ext)) { + count++; + } + } + } + } + + await walk(rootDir); + return count; +} + +// --------------------------------------------------------- +// SECONDA PASSATA: scansiona SOLO le cartelle vere +// --------------------------------------------------------- +async function scanActivitiesUser(userName, db) { + log(`🔵 Inizio scan ATTIVITÀ per user=${userName}`); + + const activitiesRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'activities'); + const userDir = path.join(activitiesRoot, userName, 'original'); + + let entries = []; + + try { + entries = await fsp.readdir(userDir, { withFileTypes: true }); + } catch { + log(`❌ Nessuna directory attività per utente ${userName}`); + return []; + } + + // Filtra SOLO cartelle vere dentro "original" + const folders = entries + .filter(e => e.isDirectory()) + .map(e => e.name); + + // --------------------------------------------------------- + // RIMOZIONE CARTELLE CANCELLATE DAL FILESYSTEM + // --------------------------------------------------------- + const createDeleteFolderFunctions = require('./deleteFolder'); + const { deleteFolderForUser } = createDeleteFolderFunctions(db); + + const dbFolders = await db('activities') + .where({ user: userName }) + .distinct('cartella') + .pluck('cartella'); + + for (const dbFolder of dbFolders) { + if (!folders.includes(dbFolder)) { + log(`🗑️ Cartella attività rimossa dal filesystem: ${dbFolder}`); + await deleteFolderForUser(userName, dbFolder); + } + } + + // PRIMA PASSATA: conta i file reali + const TOTAL_FILES = await countActivityFilesUser(userDir); + const CURRENT = { value: 0 }; + const start = Date.now(); + + log(`📊 Attività totali da scansionare: ${TOTAL_FILES}`); + + const allNewActivities = []; + + // SECONDA PASSATA: UNA SOLA SCANSIONE PER CARTELLA + for (const cartella of folders) { + log(`📁 Scan cartella attività: ${cartella}`); + + const newActivities = await scanActivity( + cartella, + userName, + db, + CURRENT, + TOTAL_FILES, + start + ); + + if (newActivities?.length) { + allNewActivities.push(...newActivities); + } + } + + log(`🟣 Scan ATTIVITÀ COMPLETATO per user=${userName}. Nuove attività: ${allNewActivities.length}`); + + return allNewActivities; +} + +module.exports = scanActivitiesUser; diff --git a/api_v1/scanner/scanActivity.js b/api_v1/scanner/scanActivity.js new file mode 100644 index 0000000..9b29972 --- /dev/null +++ b/api_v1/scanner/scanActivity.js @@ -0,0 +1,52 @@ +// api_v1/scanner/scanActivity.js +const path = require('path'); + +const { log } = require('./logger'); +const scanActivityCartella = require('./scanActivityCartella'); +const createActivityOrphanCleanup = require('./activityOrphanCleanup'); + +const { + WEB_ROOT +} = require('../config'); + +// Il progresso globale viene letto dalla coda SQLite. + // Mantiene la callback richiesta da scanActivityCartella. +async function updateStatusFile() {} + +// --------------------------------------------------------- +// SCAN UNA SOLA CARTELLA DI ATTIVITÀ +// --------------------------------------------------------- +async function scanActivity(dir, userName, db, CURRENT, TOTAL_FILES, start, onProgress) { + const newActivities = []; + + const { deleteFromDB } = createActivityOrphanCleanup(db); + + const activitiesRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'activities'); + const userDir = path.join(activitiesRoot, userName, 'original'); + + const cartella = dir; + const absCartella = path.join(userDir, cartella); + + log(`📁 [SCAN ACTIVITY CARTELLA] ${cartella}`); + + await scanActivityCartella( + db, + userName, + cartella, + absCartella, + newActivities, + updateStatusFile, + CURRENT, + TOTAL_FILES, + start, + deleteFromDB, + onProgress + ); + + // Parsing e sincronizzazione remota sono ora responsabilita + // dell'ActivityIngestDaemon. + + return newActivities; +} + +module.exports = scanActivity; diff --git a/api_v1/scanner/scanActivityCartella.js b/api_v1/scanner/scanActivityCartella.js new file mode 100644 index 0000000..86466e1 --- /dev/null +++ b/api_v1/scanner/scanActivityCartella.js @@ -0,0 +1,84 @@ +// api_v1/scanner/scanActivityCartella.js +const scanActivityFiles = require('./scanActivityFiles'); +const { + enqueueActivityIngestJobs, +} = require('./activityIngestQueue'); +const { sha256 } = require('./utils'); +const { log } = require('./logger'); + +const BATCH_SIZE = 100; + +async function scanActivityCartella( + db, + user, + cartella, + absCartella, + newActivities, + updateStatusFile, + CURRENT, + TOTAL_FILES, + start, + deleteFromDB, + onProgress +) { + log(`📁 [SCAN ACTIVITY CARTELLA] ${cartella}`); + + // Se la lettura del filesystem fallisce, lo scan deve fallire: + // non dobbiamo interpretare tutti i record come orfani. + const rows = await db('activities') + .where({ user, cartella }) + .select('id'); + + const idsSet = new Set(rows.map(r => r.id)); + const files = []; + + // Prima scopriamo tutti i file. Nessun orfano viene cancellato + // se la scoperta o l'accodamento falliscono. + for await (const f of scanActivityFiles(user, cartella, absCartella, db)) { + files.push(f); + } + + for (let offset = 0; offset < files.length; offset += BATCH_SIZE) { + const batch = files.slice(offset, offset + BATCH_SIZE); + + const jobs = batch.map(f => ({ + user, + cartella, + relPath: f.relPath, + mtimeMs: f.stat.mtimeMs, + sizeBytes: f.stat.size, + })); + + // Una transazione per batch, anziché una per file. + const results = await enqueueActivityIngestJobs(db, jobs); + + for (let i = 0; i < batch.length; i++) { + const f = batch[i]; + const result = results[i]; + + CURRENT.value++; + idsSet.delete(sha256(`${user}/${cartella}/${f.relPath}`)); + + log( + `📥 [ACTIVITY QUEUE] ${f.relPath} ` + + `status=${result.status}` + + `${result.inserted ? ' inserted' : ''}` + + `${result.updated ? ' updated' : ''}` + + `${result.unchanged ? ' unchanged' : ''}` + ); + } + + // Aggiorna l'interfaccia una volta per batch, non per file. + await updateStatusFile(CURRENT, TOTAL_FILES, start); + if (onProgress) await onProgress(); + } + + // Questa sezione viene raggiunta soltanto se tutti i batch + // sono stati accodati correttamente. + for (const orphanId of idsSet) { + log(`🔴 [ORPHAN ACTIVITY] ${orphanId}`); + await deleteFromDB(orphanId, user); + } +} + +module.exports = scanActivityCartella; diff --git a/api_v1/scanner/scanActivityFiles.js b/api_v1/scanner/scanActivityFiles.js new file mode 100644 index 0000000..726b3da --- /dev/null +++ b/api_v1/scanner/scanActivityFiles.js @@ -0,0 +1,45 @@ +// api_v1/scanner/scanActivityFiles.js +const fsp = require('fs/promises'); +const path = require('path'); + +const SUPPORTED_ACTIVITY_EXTS = new Set(['.fit', '.gpx', '.tcx']); + +async function* scanActivityFiles(user, cartella, absCartella, db) { + async function* walk(dir) { + // Gli errori non vengono nascosti: una scansione incompleta + // non deve causare la cancellazione delle attività esistenti. + const entries = await fsp.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const absPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + yield* walk(absPath); + continue; + } + + if (!entry.isFile()) continue; + + const ext = path.extname(entry.name).toLowerCase(); + if (!SUPPORTED_ACTIVITY_EXTS.has(ext)) continue; + + const stat = await fsp.stat(absPath); + const relPath = path.relative(absCartella, absPath) + .split(path.sep).join('/'); + + yield { + id: `${cartella}/${relPath}`, + file_name: entry.name, + relPath, + absPath, + ext, + stat, + path: `/activities/${user}/original/${cartella}/${relPath}`, + }; + } + } + + yield* walk(absCartella); +} + +module.exports = scanActivityFiles; diff --git a/api_v1/scanner/scanActivitySingle.js b/api_v1/scanner/scanActivitySingle.js new file mode 100644 index 0000000..caa385e --- /dev/null +++ b/api_v1/scanner/scanActivitySingle.js @@ -0,0 +1,367 @@ +// api_v1/scanner/scanActivitySingle.js +const processActivityFile = require('./processActivityFile'); // parser FIT/GPX/TCX +const { + enqueueGeocodeJob, +} = require('./geocodeQueue'); +const { sha256 } = require('./utils'); +const { log } = require('./logger'); +const { + withSqliteBusyRetry, +} = require('../../db/sqliteRetry'); + +// Se vuoi forzare il ricalcolo anche di file invariati: +// ACTIVITY_FORCE_REPARSE=1 node ... +const FORCE_REPARSE = process.env.ACTIVITY_FORCE_REPARSE === '1'; + +// Normalizza location come per le foto +function normCountryCode(value) { + if (!value) return undefined; + const s = String(value).trim().toUpperCase(); + return s.length ? s : undefined; +} + +function normalizeLocation(location) { + if (!location) return null; + + const countryCode = normCountryCode( + location.country_code || + location.countryCode || + location.isoCountryCode + ); + + const countyCode = + location.county_code || + location.countyCode || + undefined; + + return { + ...location, + country: location.country || undefined, + country_code: countryCode, + county: location.county || undefined, + county_code: countyCode, + region: location.region || undefined, + city: location.city || undefined, + postcode: location.postcode || undefined, + address: location.address || undefined, + timezone: location.timezone || undefined, + time: location.time || undefined, + }; +} + +function serializeJson(value, fallback = null) { + if (value == null) return fallback; + + try { + return JSON.stringify(value); + } catch { + return fallback; + } +} + +async function scanActivitySingle(db, user, cartella, f, newActivities, prefix = '') { + // const fileName = f.name; + const fileName = f.file_name ?? f.name ?? f.relPath ?? 'unknown'; + const st = f.stat; + + // --------------------------------------------------------- + // ID e fastHash PRIMA del parsing pesante + // Deve essere identico a processActivityFile: + // sha256(`${userName}/${cartella}/${fileRelPath}`) + // --------------------------------------------------------- + const id = sha256(`${user}/${cartella}/${f.relPath}`); + const fastHash = sha256(`${st.size}-${st.mtimeMs}`); + + // --------------------------------------------------------- + // HARD DELETED → non reimportare attività cancellate + // --------------------------------------------------------- +const hardDeleted = await db('deleted_hard_activities') + .where({ id }) + .first(); + +if (hardDeleted) { + log(`${prefix} ♻️ Activity restored: ${fileName}`); + + await withSqliteBusyRetry( + 'activity hard-delete restore', + () => db('deleted_hard_activities') + .where({ id }) + .del() + ); +} + + // --------------------------------------------------------- + // Recupero precedente usando id deterministico + // --------------------------------------------------------- + let prev = await db('activities') + .select('id', 'size_bytes', 'last_modified_millis', 'fast_hash', 'path', 'deleted_at') + .where({ id }) + .first(); + + // --------------------------------------------------------- + // PATH CAMBIATO = tratta come nuovo/modificato + // --------------------------------------------------------- + if (prev && prev.path !== f.path) { + log(`${prefix} 🟠 Percorso attività cambiato: ${fileName}`); + // Manteniamo prev: l'ID esiste già e il ramo di aggiornamento + // aggiornerà path senza tentare un INSERT duplicato. + } + + // --------------------------------------------------------- + // IDENTICO → SKIP IMMEDIATO, senza parsing FIT/GPX/TCX + // --------------------------------------------------------- + if (prev && prev.fast_hash === fastHash && !FORCE_REPARSE) { + log(`${prefix} ⏭️ Activity invariata, skip parser: ${fileName}`); + + // Aggiorno solo dati leggeri. Non tocco date_modified_millis, + // così non genero changes inutili nel progressive sync. + await withSqliteBusyRetry( + 'activity unchanged update', + () => db('activities') + .where({ id }) + .update({ + path: f.path, + size_bytes: st.size, + last_modified_millis: st.mtimeMs, + last_scan_millis: Date.now(), + fast_hash: fastHash, + deleted_at: null, + updated_at: new Date().toISOString(), + }) + ); + + return null; + } + + // --------------------------------------------------------- + // Solo qui facciamo parsing completo FIT/GPX/TCX + // --------------------------------------------------------- + const meta = await processActivityFile( + user, + cartella, + f.relPath, + f.absPath, + f.ext, + st + ); + + meta.path = f.path; + meta.location = normalizeLocation(meta.location); + + const trackPreviewJson = serializeJson(meta.track_preview || [], null); + const tagsJson = serializeJson(meta.tags || [], null); + const diagnosticsJson = serializeJson(meta.diagnostics || null, null); + const locationJson = serializeJson(meta.location || null, null); + + // --------------------------------------------------------- + // NUOVA ATTIVITÀ + // --------------------------------------------------------- + if (!prev) { + log(`${prefix} 🟢 Nuova activity: ${fileName}`); + + await withSqliteBusyRetry( + 'activity insert', + () => db('activities').insert({ + id: meta.id, + user, + cartella, + + file_name: meta.file_name, + file_extension: meta.file_extension, + format: meta.format, + display_name: meta.display_name, + route_name: meta.route_name, + title: meta.title, + description: meta.description, + notes: meta.notes, + sport: meta.sport, + activity_type: meta.activity_type, + device_manufacturer: meta.device_manufacturer, + device_model: meta.device_model, + source_app: meta.source_app, + + start_time_millis: meta.start_time_millis, + end_time_millis: meta.end_time_millis, + duration_millis: meta.duration_millis, + + distance_meters: meta.distance_meters, + elevation_gain_meters: meta.elevation_gain_meters, + elevation_loss_meters: meta.elevation_loss_meters, + + min_elevation_meters: meta.min_elevation_meters, + max_elevation_meters: meta.max_elevation_meters, + + file_distance_meters: meta.file_distance_meters, + file_elevation_gain_meters: meta.file_elevation_gain_meters, + + computed_distance_meters: meta.computed_distance_meters, + computed_elevation_gain_meters: meta.computed_elevation_gain_meters, + + average_speed_mps: meta.average_speed_mps, + max_speed_mps: meta.max_speed_mps, + average_pace_sec_per_km: meta.average_pace_sec_per_km, + + average_heart_rate: meta.average_heart_rate, + max_heart_rate: meta.max_heart_rate, + average_cadence: meta.average_cadence, + max_cadence: meta.max_cadence, + + calories: meta.calories, + power_avg: meta.power_avg, + power_max: meta.power_max, + + point_count: meta.point_count, + segment_count: meta.segment_count, + lap_count: meta.lap_count, + + min_latitude: meta.min_latitude, + max_latitude: meta.max_latitude, + min_longitude: meta.min_longitude, + max_longitude: meta.max_longitude, + center_latitude: meta.center_latitude, + center_longitude: meta.center_longitude, + + size_bytes: meta.size_bytes, + last_modified_millis: meta.last_modified_millis, + date_added_millis: meta.date_added_millis, + date_modified_millis: meta.date_modified_millis, + last_scan_millis: meta.last_scan_millis, + + is_favorite: meta.is_favorite, + is_hidden: meta.is_hidden, + rating: meta.rating, + + tags: tagsJson, + track_preview: trackPreviewJson, + + scan_status: meta.scan_status, + scan_error: meta.scan_error, + path: meta.path, + + diagnostics: diagnosticsJson, + location: locationJson, + + fast_hash: fastHash, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + ); + + await enqueueGeocodeJob({ + entityType: 'activity', + entityId: meta.id, + lat: meta.gps?.lat, + lon: meta.gps?.lng, + }); + + newActivities.push(meta); + return meta; + } + + // --------------------------------------------------------- + // MODIFICATO oppure FORCE_REPARSE + // --------------------------------------------------------- + if (FORCE_REPARSE) { + log(`${prefix} 🔁 Force reparse activity: ${fileName}`); + } else { + log(`${prefix} 🟠 Modificato activity: ${fileName}`); + } + + await withSqliteBusyRetry( + 'activity modified update', + () => db('activities') + .where({ id }) + .update({ + file_name: meta.file_name, + file_extension: meta.file_extension, + format: meta.format, + display_name: meta.display_name, + route_name: meta.route_name, + title: meta.title, + description: meta.description, + notes: meta.notes, + sport: meta.sport, + activity_type: meta.activity_type, + device_manufacturer: meta.device_manufacturer, + device_model: meta.device_model, + source_app: meta.source_app, + + start_time_millis: meta.start_time_millis, + end_time_millis: meta.end_time_millis, + duration_millis: meta.duration_millis, + + distance_meters: meta.distance_meters, + elevation_gain_meters: meta.elevation_gain_meters, + elevation_loss_meters: meta.elevation_loss_meters, + + min_elevation_meters: meta.min_elevation_meters, + max_elevation_meters: meta.max_elevation_meters, + + file_distance_meters: meta.file_distance_meters, + file_elevation_gain_meters: meta.file_elevation_gain_meters, + + computed_distance_meters: meta.computed_distance_meters, + computed_elevation_gain_meters: meta.computed_elevation_gain_meters, + + average_speed_mps: meta.average_speed_mps, + max_speed_mps: meta.max_speed_mps, + average_pace_sec_per_km: meta.average_pace_sec_per_km, + + average_heart_rate: meta.average_heart_rate, + max_heart_rate: meta.max_heart_rate, + average_cadence: meta.average_cadence, + max_cadence: meta.max_cadence, + + calories: meta.calories, + power_avg: meta.power_avg, + power_max: meta.power_max, + + point_count: meta.point_count, + segment_count: meta.segment_count, + lap_count: meta.lap_count, + + min_latitude: meta.min_latitude, + max_latitude: meta.max_latitude, + min_longitude: meta.min_longitude, + max_longitude: meta.max_longitude, + center_latitude: meta.center_latitude, + center_longitude: meta.center_longitude, + + size_bytes: meta.size_bytes, + last_modified_millis: meta.last_modified_millis, + date_added_millis: meta.date_added_millis, + date_modified_millis: meta.date_modified_millis, + last_scan_millis: meta.last_scan_millis, + + is_favorite: meta.is_favorite, + is_hidden: meta.is_hidden, + rating: meta.rating, + + tags: tagsJson, + track_preview: trackPreviewJson, + + scan_status: meta.scan_status, + scan_error: meta.scan_error, + path: meta.path, + + diagnostics: diagnosticsJson, + location: locationJson, + + fast_hash: fastHash, + deleted_at: null, + updated_at: new Date().toISOString(), + }) + ); + + await enqueueGeocodeJob({ + entityType: 'activity', + entityId: meta.id, + lat: meta.gps?.lat, + lon: meta.gps?.lng, + }); + + newActivities.push(meta); + return meta; +} + +module.exports = scanActivitySingle; diff --git a/api_v1/scanner/scanAuto.js b/api_v1/scanner/scanAuto.js new file mode 100644 index 0000000..021511e --- /dev/null +++ b/api_v1/scanner/scanAuto.js @@ -0,0 +1,211 @@ +const path = require('path'); +const fsp = require('fs/promises'); +const scanCartella = require('./scanCartella'); +const processFile = require('./processFile'); +const { sha256 } = require('./utils'); +const createCleanupFunctions = require('./orphanCleanup'); +const { WEB_ROOT } = require('../config'); +const { log } = require('./logger'); + + +// --------------------------------------------------------- +// NORMALIZZAZIONE PATH (toglie /app/...) +// --------------------------------------------------------- +function normalizeRelPath(user, dirPath, fileName) { + if (!dirPath) return ""; + + let rel = dirPath.replace(/\\/g, "/"); + + // Rimuove tutto fino a /photos/ + rel = rel.replace(/^.*\/photos\//, ""); + + // Ora rel = "Fabio/original/2017Irlanda/.../" + const prefix = `${user}/original/`; + + if (rel.startsWith(prefix)) { + rel = rel.slice(prefix.length); + } + + if (rel.startsWith("/")) rel = rel.slice(1); + + // Aggiunge il file + if (fileName) { + rel = `${rel}/${fileName}`.replace(/\/+/g, "/"); + } + + return rel; // es: "2017Irlanda19-29ago/IMG_0120.JPG" +} + + +// --------------------------------------------------------- +// CALCOLO ID IDENTICO AL VECCHIO SCAN +// --------------------------------------------------------- +function computeId(user, relPath) { + const parts = relPath.split('/').filter(Boolean); + const cartella = parts[0]; + const fileRelPath = parts.slice(1).join('/'); + const id = sha256(`${user}/${cartella}/${fileRelPath}`); + return { id, cartella, fileRelPath }; +} + + + +// --------------------------------------------------------- +// 1) ADD FILE +// --------------------------------------------------------- +async function handleAddFile(user, dirPath, fileName, db) { + const relPath = normalizeRelPath(user, dirPath, fileName); + + log(`🟦 [ADD] user=${user} file=${relPath}`); + + // 🔥 Ricostruzione identica al vecchio scanPhoto + const parts = relPath.split('/'); + const cartella = parts[0]; // es: "2017Irlanda19-29ago" + const fileRelPath = parts.slice(1).join('/'); // es: "IMG_0116.JPG" + + const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos'); + const absPath = path.join(photosRoot, user, 'original', cartella, fileRelPath); + + const ext = path.extname(fileName).toLowerCase(); + const st = await fsp.stat(absPath); + + // 🔥 FIX: processFile deve ricevere fileRelPath completo di "original/" + const meta = await processFile( + user, + cartella, + path.posix.join('original', cartella, fileRelPath), + absPath, + ext, + st + ); + + // Inserimento identico al vecchio scanPhoto + const row = { + id: meta.id, + user, + cartella, + name: meta.name, + path: meta.path, + thub1: meta.thub1, + thub2: meta.thub2, + cast: meta.cast, + mime_type: meta.mime_type, + width: meta.width, + height: meta.height, + rotation: meta.rotation, + size_bytes: meta.size_bytes, + mtimeMs: meta.mtimeMs, + duration_ms: meta.duration_ms ?? null, + taken_at: meta.taken_at ?? null, + data: meta.data ?? null, + lat: meta.gps?.lat ?? null, + lon: meta.gps?.lng ?? null, + alt: meta.gps?.alt ?? null, + location: meta.location ? JSON.stringify(meta.location) : null + }; + + await db('photos') + .insert(row) + .onConflict('id') + .merge(); + + log(`🟢 [ADD] Inserito/aggiornato id=${meta.id}`); + + return row; +} + + +// --------------------------------------------------------- +// 2) DELETE FILE +// --------------------------------------------------------- +async function handleDeleteFile(user, dirPath, fileName, db) { + const relPath = normalizeRelPath(user, dirPath, fileName); + + log(`🟥 [DEL] user=${user} file=${relPath}`); + + const { id } = computeId(user, relPath); + + const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db); + + await deleteThumbsById(id); + await deleteFromDB(id, user); + + log(`🔴 [DEL] File eliminato id=${id}`); + + return { deleted: id }; +} + + + +// --------------------------------------------------------- +// 3) ADD DIRECTORY +// --------------------------------------------------------- +async function handleAddDir(user, dir, db) { + log(`🟦 [ADD_DIR] user=${user} dir=${dir}`); + + const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos'); + const absDir = path.join(photosRoot, user, 'original', dir); + + log(`📁 [ADD_DIR] absDir=${absDir}`); + + let results = []; + + for await (const f of scanCartella(user, dir, absDir, db)) { + log(`📄 [ADD_DIR] File trovato: ${f.relPath}`); + + const meta = await processFile( + user, + dir, + f.relPath, + f.absPath, + f.ext, + f.stat + ); + + await db('photos').insert(meta).onConflict('id').merge(); + + log(`🟢 [ADD_DIR] Inserito/aggiornato id=${meta.id} name=${meta.name}`); + + results.push(meta); + } + + log(`🟣 [ADD_DIR] Completato. Files=${results.length}`); + + return results; +} + + + +// --------------------------------------------------------- +// 4) DELETE DIRECTORY +// --------------------------------------------------------- +async function handleDeleteDir(user, dir, db) { + log(`🟥 [DEL_DIR] user=${user} dir=${dir}`); + + const rows = await db('photos') + .where({ user, cartella: dir }) + .select('id'); + + const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db); + + log(`📁 [DEL_DIR] Trovati ${rows.length} file da eliminare`); + + for (const r of rows) { + log(`🗑️ [DEL_DIR] Eliminazione id=${r.id}`); + await deleteThumbsById(r.id); + await deleteFromDB(r.id, user); + } + + log(`🔴 [DEL_DIR] Cartella eliminata. Totale file=${rows.length}`); + + return { deleted: rows.length }; +} + + + +module.exports = { + handleAddFile, + handleDeleteFile, + handleAddDir, + handleDeleteDir +}; \ No newline at end of file diff --git a/api_v1/scanner/scanCartella.js b/api_v1/scanner/scanCartella.js new file mode 100644 index 0000000..72aff2f --- /dev/null +++ b/api_v1/scanner/scanCartella.js @@ -0,0 +1,67 @@ +// api_v1/scanner/scanCartella.js +const path = require('path'); +const fsp = require('fs/promises'); +const fs = require('fs'); +const { sha256 } = require('./utils'); +const { SUPPORTED_EXTS } = require('../config'); +const { log } = require('./logger'); + +/** + * Scansiona ricorsivamente la cartella: + * /photos//original// + * + * Restituisce (yield) i metadati dei file trovati. + */ +async function* scanCartella(userName, cartella, absCartella, db) { + + async function* walk(currentAbs, relPath = '') { + let entries = []; + try { + entries = await fsp.readdir(currentAbs, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const absPath = path.join(currentAbs, e.name); + + if (e.isDirectory()) { + yield* walk(absPath, path.join(relPath, e.name)); + continue; + } + + const ext = path.extname(e.name).toLowerCase(); + if (!SUPPORTED_EXTS.has(ext)) continue; + + const fileRelPath = relPath ? `${relPath}/${e.name}` : e.name; + + // ID deterministico basato sul percorso + const id = sha256(`${userName}/${cartella}/${fileRelPath}`); + + let st; + try { + st = await fsp.stat(absPath); + } catch { + continue; + } + + // Log per-file disabilitato: rallenta significativamente lo scan. + + yield { + id, + user: userName, + cartella, + name: e.name, + relPath: fileRelPath, + absPath, + ext, + stat: st, + path: `/photos/${userName}/original/${cartella}/${fileRelPath}` + }; + } + } + + yield* walk(absCartella); +} + +module.exports = scanCartella; + diff --git a/api_v1/scanner/scanFile.js b/api_v1/scanner/scanFile.js new file mode 100644 index 0000000..66eb4fb --- /dev/null +++ b/api_v1/scanner/scanFile.js @@ -0,0 +1,235 @@ +// api_v1/scanner/scanFile.js +const path = require('path'); +const fsp = require('fs/promises'); +const { sha256 } = require('./utils'); +const { SUPPORTED_EXTS } = require('../config'); +const { log } = require('./logger'); + +/** + * scanFile: genera informazioni su UN singolo file + * Restituisce lo stesso formato di scanCartella, ma senza recursion. + * + * Parametri: + * - userName + * - cartella + * - absFile (percorso assoluto del file) + * - db (opzionale) + */ +async function scanFileEntry(userName, cartella, absFile, db) { + const ext = path.extname(absFile).toLowerCase(); + if (!SUPPORTED_EXTS.has(ext)) { + log(`⛔ Estensione non supportata: ${ext}`); + return null; + } + + const name = path.basename(absFile); + const relPath = name; // singolo file → niente struttura ricorsiva + + let st; + try { + st = await fsp.stat(absFile); + } catch { + log(`⛔ Impossibile leggere stat per ${absFile}`); + return null; + } + + const id = sha256(`${userName}/${cartella}/${relPath}`); + + log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`); + + return { + id, + user: userName, + cartella, + name, + relPath, + absPath: absFile, + ext, + stat: st, + path: `/photos/${userName}/original/${cartella}/${relPath}` + }; +} + +async function scanFile(userName, cartella, absFile, db) { + const f = await scanFile(user, cart, absFile); + const fileName = f.name; + const id = f.id; + const st = f.stat; + + + let prev = await db("photos") + .select("id", "size_bytes", "mtimeMs", "_indexHash", "fast_hash", "path") + .where({ id }) + .first(); + + if (prev && LOG_VERBOSE) { + log(`${prefix} ⚪ Invariato: ${fileName}`); + } + + const fastHash = sha256(`${st.size}-${st.mtimeMs}`); + + // --------------------------------------------------------- + // PATH CAMBIATO = NUOVO FILE + // --------------------------------------------------------- + if (prev && prev.path !== f.path) { + log(`${prefix} 🟢 Nuovo/Modificato: ${fileName}`); + prev = null; + } + + // --------------------------------------------------------- + // NUOVO FILE + // --------------------------------------------------------- + if (!prev) { + log(`${prefix} 🟢 Nuovo/Modificato: ${fileName}`); + + const meta = await processFile( + user, + cartella, + f.relPath, + f.absPath, + f.ext, + st + ); + + meta.id = id; + meta.path = f.path; + + // INSERT CORRETTO (senza colonna gps) + await db("photos").insert({ + id: meta.id, + user, + cartella, + name: meta.name, + path: meta.path, + thub1: meta.thub1, + thub2: meta.thub2, + cast: meta.cast, + mime_type: meta.mime_type, + width: meta.width, + height: meta.height, + rotation: meta.rotation, + size_bytes: meta.size_bytes, + mtimeMs: meta.mtimeMs, + duration_ms: meta.duration_ms, + taken_at: meta.taken_at, + data: meta.data, + lat: meta.gps?.lat ?? null, + lon: meta.gps?.lng ?? null, + alt: meta.gps?.alt ?? null, + location: meta.location ? JSON.stringify(meta.location) : null, + _indexHash: meta._indexHash, + fast_hash: fastHash + }); + + await db('photo_changes').insert({ + photo_id: meta.id, + user, + change_type: 'added', + timestamp: new Date().toISOString() + }); + + + newFiles.push(meta); + //log(`🟢 [PUSH newFiles] id=${meta.id} path=${meta.path}`); + + return; + } + + // --------------------------------------------------------- + // FAST-SIZE-SKIP + // --------------------------------------------------------- + if (prev.size_bytes === st.size) { + //log(`🔵 [FAST-SIZE-SKIP] id=${id}`); + + await db("photos") + .where({ id }) + .update({ + path: f.path, + size_bytes: st.size, + mtimeMs: st.mtimeMs, + fast_hash: fastHash + }); + + continue; + } + + // --------------------------------------------------------- + // FAST-HASH-SKIP + // --------------------------------------------------------- + if (prev.fast_hash === fastHash) { + //log(`🔵 [FAST-HASH-SKIP] id=${id}`); + + await db("photos") + .where({ id }) + .update({ + path: f.path, + size_bytes: st.size, + mtimeMs: st.mtimeMs + }); + + continue; + } + + // --------------------------------------------------------- + // MODIFICATO + // --------------------------------------------------------- + //log(`🟠 [FULL-SCAN] id=${id}`); + log(`${prefix} 🟠 Nuovo/Modificato: ${fileName}`); + const meta = await processFile( + user, + cartella, + f.relPath, + f.absPath, + f.ext, + st + ); + + meta.id = id; + meta.path = f.path; + + await db("photos") + .insert({ + id: meta.id, + user, + cartella, + name: meta.name, + path: meta.path, + thub1: meta.thub1, + thub2: meta.thub2, + cast: meta.cast, + mime_type: meta.mime_type, + width: meta.width, + height: meta.height, + rotation: meta.rotation, + size_bytes: meta.size_bytes, + mtimeMs: meta.mtimeMs, + duration_ms: meta.duration_ms, + taken_at: meta.taken_at, + data: meta.data, + lat: meta.gps?.lat ?? null, + lon: meta.gps?.lng ?? null, + alt: meta.gps?.alt ?? null, + location: meta.location ? JSON.stringify(meta.location) : null, + _indexHash: meta._indexHash, + fast_hash: fastHash + }) + .onConflict("id") + .merge(); + + await db('photo_changes').insert({ + photo_id: meta.id, + user, + change_type: 'updated', + timestamp: new Date().toISOString() + }); + + newFiles.push(meta); + log(`${prefix} 🟠 Nuovo/Modificato al server ${fileName}`); + } + + } + + +} + +module.exports = scanFile; diff --git a/api_v1/scanner/scanFile1.js b/api_v1/scanner/scanFile1.js new file mode 100644 index 0000000..85afa37 --- /dev/null +++ b/api_v1/scanner/scanFile1.js @@ -0,0 +1,53 @@ +// api_v1/scanner/scanFile.js +const path = require('path'); +const fsp = require('fs/promises'); +const { sha256 } = require('./utils'); +const { SUPPORTED_EXTS } = require('../config'); +const { log } = require('./logger'); + +/** + * scanFile: genera informazioni su UN singolo file + * Restituisce lo stesso formato di scanCartella, ma senza recursion. + * + * Parametri: + * - userName + * - cartella + * - absFile (percorso assoluto del file) + * - db (opzionale) + */ +async function scanFile(userName, cartella, absFile, db) { + const ext = path.extname(absFile).toLowerCase(); + if (!SUPPORTED_EXTS.has(ext)) { + log(`⛔ Estensione non supportata: ${ext}`); + return null; + } + + const name = path.basename(absFile); + const relPath = name; // singolo file → niente struttura ricorsiva + + let st; + try { + st = await fsp.stat(absFile); + } catch { + log(`⛔ Impossibile leggere stat per ${absFile}`); + return null; + } + + const id = sha256(`${userName}/${cartella}/${relPath}`); + + log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`); + + return { + id, + user: userName, + cartella, + name, + relPath, + absPath: absFile, + ext, + stat: st, + path: `/photos/${userName}/original/${cartella}/${relPath}` + }; +} + +module.exports = scanFile; diff --git a/api_v1/scanner/scanFileEntry.js b/api_v1/scanner/scanFileEntry.js new file mode 100644 index 0000000..a7b31b9 --- /dev/null +++ b/api_v1/scanner/scanFileEntry.js @@ -0,0 +1,55 @@ +// api_v1/scanner/scanFileEntry.js +const path = require('path'); +const fsp = require('fs/promises'); +const { sha256 } = require('./utils'); +const { SUPPORTED_EXTS } = require('../config'); +const { log } = require('./logger'); + +/** + * scanFile: genera informazioni su UN singolo file + * Restituisce lo stesso formato di scanCartella, ma senza recursion. + * + * Parametri: + * - userName + * - cartella + * - absFile (percorso assoluto del file) + * - db (opzionale) + */ +async function scanFile(userName, cartella, absFile, db) { + const ext = path.extname(absFile).toLowerCase(); + if (!SUPPORTED_EXTS.has(ext)) { + log(`⛔ Estensione non supportata: ${ext}`); + return null; + } + + const name = path.basename(absFile); + const relPath = name; // singolo file → niente struttura ricorsiva + + // ID deterministico (funziona anche se il file non esiste) + const id = sha256(`${userName}/${cartella}/${relPath}`); + + let st = null; + try { + st = await fsp.stat(absFile); + } catch { + // File NON esiste → caso DEL + log(`⛔ Impossibile leggere stat per ${absFile} (file mancante)`); + // Continuiamo comunque: l'ID è valido e serve per DEL + } + + log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`); + + return { + id, + user: userName, + cartella, + name, + relPath, + absPath: absFile, + ext, + stat: st, // può essere null + path: `/photos/${userName}/original/${cartella}/${relPath}` + }; +} + +module.exports = scanFile; diff --git a/api_v1/scanner/scanNewCartella.js b/api_v1/scanner/scanNewCartella.js new file mode 100644 index 0000000..b02d695 --- /dev/null +++ b/api_v1/scanner/scanNewCartella.js @@ -0,0 +1,76 @@ +// api_v1/scanner/scanNewCartella.js +const path = require('path'); +const fsp = require('fs/promises'); +const { WEB_ROOT, SUPPORTED_EXTS } = require('../config'); +const scanPhoto = require('./scanPhoto'); + +// --------------------------------------------------------- +// Conta ricorsivamente i file in /photos//original// +// --------------------------------------------------------- +async function countFilesInUserFolder(userName, folderName) { + const rootDir = path.join(WEB_ROOT, userName, "original", folderName); + let count = 0; + + async function walk(dir) { + let entries = []; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const e of entries) { + const abs = path.join(dir, e.name); + + if (e.isDirectory()) { + await walk(abs); + } else { + const ext = path.extname(e.name).toLowerCase(); + if (SUPPORTED_EXTS.has(ext)) { + count++; + } + } + } + } + + await walk(rootDir); + return count; +} + +// --------------------------------------------------------- +// Crea thumbs/ e scansiona la cartella come fa /scan +// --------------------------------------------------------- +async function scanNewCartella(userName, folderName, db) { + // 1) crea la cartella root in thumbs + const thumbsDir = path.join(WEB_ROOT, userName, "thumbs", folderName); + await fsp.mkdir(thumbsDir, { recursive: true }); + + // 2) conta i file ricorsivamente + const TOTAL_FILES = await countFilesInUserFolder(userName, folderName); + + // 3) esegui lo scan della cartella + const CURRENT = { value: 0 }; + const start = Date.now(); + + // ⭐ newFiles conterrà i meta delle foto nuove + const newFiles = await scanPhoto( + folderName, + userName, + db, + CURRENT, + TOTAL_FILES, + start + ); + + return { + ok: true, + folder: folderName, + totalFiles: TOTAL_FILES, + newFiles + }; +} + +module.exports = { + scanNewCartella, + countFilesInUserFolder +}; \ No newline at end of file diff --git a/api_v1/scanner/scanPhoto.js b/api_v1/scanner/scanPhoto.js new file mode 100644 index 0000000..e8ac12d --- /dev/null +++ b/api_v1/scanner/scanPhoto.js @@ -0,0 +1,111 @@ +// api_v1/scanner/scanPhoto.js +const path = require('path'); + +const { log } = require('./logger'); +const scanPhotoCartella = require('./scanPhotoCartella'); +const postWithAuth = require('./postWithAuth'); +const createCleanupFunctions = require('./orphanCleanup'); +const writeJsonAtomic = require('./writeJsonAtomic'); + +const { + WEB_ROOT, + SEND_PHOTOS, + BASE_URL +} = require('../config'); + +// --------------------------------------------------------- +// FORMAT TIME +// --------------------------------------------------------- +function formatTime(ms) { + const sec = Math.floor(ms / 1000); + const h = String(Math.floor(sec / 3600)).padStart(2, '0'); + const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0'); + const s = String(sec % 60).padStart(2, '0'); + return `${h}:${m}:${s}`; +} + +// --------------------------------------------------------- +// UPDATE STATUS FILE +// --------------------------------------------------------- +async function updateStatusFile(CURRENT, TOTAL_FILES, start) { + const now = Date.now(); + const elapsedMs = now - start; + const avg = elapsedMs / Math.max(CURRENT.value, 1); + const remainingMs = (TOTAL_FILES - CURRENT.value) * avg; + + const percent = TOTAL_FILES > 0 + ? Number(((CURRENT.value / TOTAL_FILES) * 100).toFixed(2)) + : 100; + + const status = { + current: CURRENT.value, + total: TOTAL_FILES, + percent, + eta: formatTime(remainingMs), + elapsed: formatTime(elapsedMs) + }; + + const statusPath = path.resolve( + __dirname, + '..', + '..', + 'public/photos/scan_status.json' + ); + + await writeJsonAtomic(statusPath, status); +} + +// --------------------------------------------------------- +// SCAN UNA SOLA CARTELLA +// --------------------------------------------------------- +async function scanPhoto(dir, userName, db, CURRENT, TOTAL_FILES, start, onProgress) { + const newFiles = []; + + const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db); + + const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos'); + const userDir = path.join(photosRoot, userName, 'original'); + + const cartella = dir; + const absCartella = path.join(userDir, cartella); + + log(`📁 [SCAN CARTELLA] ${cartella}`); + + await scanPhotoCartella( + db, + userName, + cartella, + absCartella, + newFiles, + updateStatusFile, + CURRENT, + TOTAL_FILES, + start, + deleteThumbsById, + deleteFromDB, + onProgress + ); + + // --------------------------------------------------------- + // INVIO AL SERVER REMOTO + // --------------------------------------------------------- + if (SEND_PHOTOS && BASE_URL && newFiles.length > 0) { + log(`📤 [SEND START] newFiles=${newFiles.length}`); + + for (const p of newFiles) { + try { + p.user = userName; + await postWithAuth(`${BASE_URL}/photos`, p); + log(`📥 [SENT TO SERVER] ${p.name}`); + } catch (err) { + log(`❌ [SERVER SENT ERROR] ${p.name} → ${err.message}`); + } + } + } else { + log(`⚠️ [NO SEND] newFiles=${newFiles.length}`); + } + + return newFiles; +} + +module.exports = scanPhoto; diff --git a/api_v1/scanner/scanPhotoCartella.js b/api_v1/scanner/scanPhotoCartella.js new file mode 100644 index 0000000..6c515d5 --- /dev/null +++ b/api_v1/scanner/scanPhotoCartella.js @@ -0,0 +1,141 @@ +// api_v1/scanner/scanPhotoCartella.js + +const scanCartella = require('./scanCartella'); +const scanPhotoSingle = require('./scanPhotoSingle'); +const { log } = require('./logger'); + +async function scanPhotoCartella( + db, + user, + cartella, + absCartella, + newFiles, + updateStatusFile, + CURRENT, + TOTAL_FILES, + start, + deleteThumbsById, + deleteFromDB, + onProgress +) { + log(`📁 Scan cartella: ${cartella}`); + let lastProgress = 0; + + // --------------------------------------------------------- + // CARICA TUTTI I RECORD DELLA CARTELLA UNA SOLA VOLTA + // --------------------------------------------------------- + + const rows = await db('photos') + .where({ user, cartella }) + .select( + 'id', + 'size_bytes', + 'mtimeMs', + 'fast_hash', + 'path' + ); + + // Map: id -> record DB + const photosMap = new Map( + rows.map(row => [row.id, row]) + ); + + // Manteniamo il Set per individuare gli orfani + const idsSet = new Set( + rows.map(row => row.id) + ); + + log( + `📊 DB cartella=${cartella}: ${rows.length} record` + ); + + // --------------------------------------------------------- + // SCANSIONE FILESYSTEM + // --------------------------------------------------------- + + for await ( + const f of scanCartella( + user, + cartella, + absCartella, + db + ) + ) { + // Aggiorna contatore + CURRENT.value++; + + // --------------------------------------------------------- + // PROGRESS THROTTLED + // + // Evita una scrittura su disco per ogni singolo file. + // Aggiorniamo al massimo ogni 500 ms. + // --------------------------------------------------------- + + const now = Date.now(); + + if (now - lastProgress >= 500) { + lastProgress = now; + + if (updateStatusFile) { + await updateStatusFile( + CURRENT, + TOTAL_FILES, + start + ); + } + + if (onProgress) { + await onProgress(); + } + } + + // Recupera il record DB dalla Map + const prev = photosMap.get(f.id) || null; + + // Processa il file + await scanPhotoSingle( + db, + user, + cartella, + f, + newFiles, + '', + prev + ); + + // Il file esiste realmente nel filesystem: + // quindi non è un orfano. + idsSet.delete(f.id); + } + + + // --------------------------------------------------------- + // ULTIMO AGGIORNAMENTO PROGRESS + // --------------------------------------------------------- + + if (updateStatusFile) { + await updateStatusFile( + CURRENT, + TOTAL_FILES, + start + ); + } + + if (onProgress) { + await onProgress(); + } + + // --------------------------------------------------------- + // GESTIONE ORFANI + // --------------------------------------------------------- + + + for (const orphanId of idsSet) { + log(`🔴 Orfano: ${orphanId}`); + + await deleteThumbsById(orphanId); + await deleteFromDB(orphanId, user); + } +} + +module.exports = scanPhotoCartella; diff --git a/api_v1/scanner/scanPhotoSingle.js b/api_v1/scanner/scanPhotoSingle.js new file mode 100644 index 0000000..df95992 --- /dev/null +++ b/api_v1/scanner/scanPhotoSingle.js @@ -0,0 +1,423 @@ +// api_v1/scanner/scanPhotoSingle.js + +const path = require('path'); +const fsp = require('fs/promises'); + +const processFile = require('./processFile'); +const { + enqueueGeocodeJob, +} = require('./geocodeQueue'); +const { sha256 } = require('./utils'); +const { log } = require('./logger'); +const { WEB_ROOT } = require('../config'); + +function normCountryCode(value) { + if (!value) return undefined; + + const s = String(value).trim().toUpperCase(); + + return s.length ? s : undefined; +} + +function normalizeLocation(location) { + if (!location) return null; + + const countryCode = normCountryCode( + location.country_code || + location.countryCode || + location.isoCountryCode + ); + + const countyCode = + location.county_code || + location.countyCode || + undefined; + + return { + ...location, + + // Paese + country: location.country || undefined, + country_code: countryCode, + + // County / contea + county: location.county || undefined, + county_code: countyCode, + + // Campi già esistenti + region: location.region || undefined, + city: location.city || undefined, + postcode: location.postcode || undefined, + address: location.address || undefined, + timezone: location.timezone || undefined, + time: location.time || undefined, + }; +} + +// --------------------------------------------------------- +// Controlla se un file esiste fisicamente +// --------------------------------------------------------- + +async function fileExists(filePath) { + try { + await fsp.access(filePath); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------- +// Controlla i thumbnail fisici +// +// FOTO -> min + avg + cast +// VIDEO -> min + avg +// --------------------------------------------------------- + +async function thumbnailsExist(user, cartella, f) { + const thumbBase = path.join( + WEB_ROOT, + 'photos', + user, + 'thumbs', + cartella, + path.dirname(f.relPath) + ); + + const parsed = path.parse(f.name); + const baseName = parsed.name; + + const thumbMin = path.join( + thumbBase, + `${baseName}_min.jpg` + ); + + const thumbAvg = path.join( + thumbBase, + `${baseName}_avg.jpg` + ); + + const isVideo = [ + '.mp4', + '.mov', + '.m4v' + ].includes(f.ext.toLowerCase()); + + // I video NON hanno cast + if (isVideo) { + return ( + await fileExists(thumbMin) && + await fileExists(thumbAvg) + ); + } + // Le foto richiedono soltanto MIN e AVG. + // La variante CAST viene generata esclusivamente on demand + // da POST /share/prepare-cast. + return ( + await fileExists(thumbMin) && + await fileExists(thumbAvg) + ); + +} + +async function scanPhotoSingle( + db, + user, + cartella, + f, + newFiles, + prefix = '', + prev = null +) { + const fileName = f.name; + const id = f.id; + const st = f.stat; + + // Hash veloce basato su size + mtime + const fastHash = sha256( + `${st.size}-${st.mtimeMs}` + ); + + // --------------------------------------------------------- + // PATH CAMBIATO = NUOVO FILE + // --------------------------------------------------------- + + if (prev && prev.path !== f.path) { + log( + `${prefix} 🟢 Nuovo/Modificato (path changed): ${fileName}` + ); + + prev = null; + } + + // --------------------------------------------------------- + // NUOVO FILE + // --------------------------------------------------------- + + if (!prev) { + const profileStart = process.hrtime.bigint(); + log(`${prefix} 🟢 Nuovo: ${fileName}`); + + const processStart = process.hrtime.bigint(); + + const meta = await processFile( + user, + cartella, + f.relPath, + f.absPath, + f.ext, + st + ); + + const processMs = + Number(process.hrtime.bigint() - processStart) / 1e6; + + log( + `⏱️ [PROFILE] ${fileName} processFile=${processMs.toFixed(2)} ms` + ); + + meta.id = id; + meta.path = f.path; + + // Normalizza location prima di salvarla e inviarla + meta.location = normalizeLocation( + meta.location + ); + + log( + `🌍 [LOCATION NEW] ${fileName} ` + + `location=${JSON.stringify(meta.location)}` + ); + + const dbStart = process.hrtime.bigint(); + await db('photos').insert({ + id: meta.id, + user, + cartella, + name: meta.name, + path: meta.path, + thub1: meta.thub1, + thub2: meta.thub2, + cast: meta.cast, + mime_type: meta.mime_type, + width: meta.width, + height: meta.height, + rotation: meta.rotation, + size_bytes: meta.size_bytes, + mtimeMs: meta.mtimeMs, + duration_ms: meta.duration_ms, + taken_at: meta.taken_at, + data: meta.data, + lat: meta.gps?.lat ?? null, + lon: meta.gps?.lng ?? null, + alt: meta.gps?.alt ?? null, + + // Qui ora il JSON può contenere: + // country, country_code, county, county_code, + // region, city, ecc. + location: meta.location + ? JSON.stringify(meta.location) + : null, + + fast_hash: fastHash, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + const dbMs = + Number(process.hrtime.bigint() - dbStart) / 1e6; + + log( + `⏱️ [PROFILE] ${fileName} DB insert=${dbMs.toFixed(2)} ms` + ); + + const geocodeStart = process.hrtime.bigint(); + await enqueueGeocodeJob({ + entityType: 'photo', + entityId: meta.id, + lat: meta.gps?.lat, + lon: meta.gps?.lng, + }); + const geocodeMs = + Number(process.hrtime.bigint() - geocodeStart) / 1e6; + + const totalMs = + Number(process.hrtime.bigint() - profileStart) / 1e6; + + log( + `⏱️ [PROFILE] ${fileName} ` + + `geocode=${geocodeMs.toFixed(2)} ms ` + + `TOTAL=${totalMs.toFixed(2)} ms` + ); + + newFiles.push(meta); + + return meta; + } + + // --------------------------------------------------------- + // IDENTICO (fastHash uguale) + // + // Se il file è identico: + // - se tutti i thumbnail esistono -> non fare niente + // - se manca anche un solo thumbnail -> riprocessa + // + // FOTO -> min + avg + cast + // VIDEO -> min + avg + // --------------------------------------------------------- + + if (prev.fast_hash === fastHash) { + const thumbsOk = await thumbnailsExist( + user, + cartella, + f + ); + + if (thumbsOk) { + return null; + } + + + // ------------------------------------------------------- + // File identico ma thumbnail mancanti + // ------------------------------------------------------- + + log( + `${prefix} 🟡 File identico ma thumbnail mancanti: ${fileName}` + ); + + const meta = await processFile( + user, + cartella, + f.relPath, + f.absPath, + f.ext, + st + ); + + meta.id = id; + meta.path = f.path; + + // Normalizza location + meta.location = normalizeLocation( + meta.location + ); + + log( + `🌍 [LOCATION THUMBS REBUILD] ${fileName} ` + + `location=${JSON.stringify(meta.location)}` + ); + + await db('photos') + .where({ id }) + .update({ + name: meta.name, + path: meta.path, + thub1: meta.thub1, + thub2: meta.thub2, + cast: meta.cast, + mime_type: meta.mime_type, + width: meta.width, + height: meta.height, + rotation: meta.rotation, + size_bytes: meta.size_bytes, + mtimeMs: meta.mtimeMs, + duration_ms: meta.duration_ms, + taken_at: meta.taken_at, + data: meta.data, + lat: meta.gps?.lat ?? null, + lon: meta.gps?.lng ?? null, + alt: meta.gps?.alt ?? null, + + location: meta.location + ? JSON.stringify(meta.location) + : null, + + fast_hash: fastHash, + updated_at: new Date().toISOString(), + }); + + await enqueueGeocodeJob({ + entityType: 'photo', + entityId: meta.id, + lat: meta.gps?.lat, + lon: meta.gps?.lng, + }); + + newFiles.push(meta); + + return meta; + } + + // --------------------------------------------------------- + // MODIFICATO (fastHash diverso) + // --------------------------------------------------------- + + log(`${prefix} 🟠 Modificato: ${fileName}`); + + const meta = await processFile( + user, + cartella, + f.relPath, + f.absPath, + f.ext, + st + ); + + meta.id = id; + meta.path = f.path; + + // Normalizza location prima di salvarla e inviarla + meta.location = normalizeLocation( + meta.location + ); + + log( + `🌍 [LOCATION UPDATED] ${fileName} ` + + `location=${JSON.stringify(meta.location)}` + ); + + await db('photos') + .where({ id }) + .update({ + name: meta.name, + path: meta.path, + thub1: meta.thub1, + thub2: meta.thub2, + mime_type: meta.mime_type, + width: meta.width, + height: meta.height, + rotation: meta.rotation, + size_bytes: meta.size_bytes, + mtimeMs: meta.mtimeMs, + duration_ms: meta.duration_ms, + taken_at: meta.taken_at, + data: meta.data, + lat: meta.gps?.lat ?? null, + lon: meta.gps?.lng ?? null, + alt: meta.gps?.alt ?? null, + + // Qui ora il JSON può contenere: + // country, country_code, county, county_code, + // region, city, ecc. + location: meta.location + ? JSON.stringify(meta.location) + : null, + + fast_hash: fastHash, + updated_at: new Date().toISOString(), + }); + + await enqueueGeocodeJob({ + entityType: 'photo', + entityId: meta.id, + lat: meta.gps?.lat, + lon: meta.gps?.lng, + }); + + newFiles.push(meta); + + return meta; +} + +module.exports = scanPhotoSingle; diff --git a/api_v1/scanner/scanPhotosUser.js b/api_v1/scanner/scanPhotosUser.js new file mode 100644 index 0000000..18c6ad2 --- /dev/null +++ b/api_v1/scanner/scanPhotosUser.js @@ -0,0 +1,143 @@ +'use strict'; + +// Scansione completa: individua i file e li accoda. +// L'elaborazione effettiva è responsabilità di photoIngestWorker. + +const path = require('path'); +const fsp = require('fs/promises'); +const crypto = require('crypto'); + +const { WEB_ROOT, SUPPORTED_EXTS } = require('../config'); +const { log } = require('./logger'); +const scanCartella = require('./scanCartella'); +const { + ensurePhotoIngestQueue, + enqueuePhotoIngestJob, +} = require('./photoIngestQueue'); + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +async function thumbnailsExist(userName, cartella, relPath) { + const parsed = path.parse(relPath); + const thumbDir = path.resolve( + __dirname, + '..', + '..', + WEB_ROOT, + 'photos', + userName, + 'thumbs', + cartella, + parsed.dir + ); + + const base = path.join(thumbDir, parsed.name); + + try { + await Promise.all([ + fsp.access(`${base}_min.jpg`), + fsp.access(`${base}_avg.jpg`), + ]); + return true; + } catch { + return false; + } +} + +async function scanPhotosUser(userName, db) { + log(`🔵 Inizio scansione e accodamento per user=${userName}`); + + const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos'); + const userDir = path.join(photosRoot, userName, 'original'); + + let entries; + + try { + entries = await fsp.readdir(userDir, { withFileTypes: true }); + } catch { + log(`❌ Nessuna directory per utente ${userName}`); + return []; + } + + const folders = entries + .filter(entry => entry.isDirectory()) + .map(entry => entry.name); + + // Mantiene la pulizia delle cartelle eliminate dal filesystem. + const { deleteFolderForUser } = require('./deleteFolder')(db); + + const dbFolders = await db('photos') + .where({ user: userName }) + .distinct('cartella') + .pluck('cartella'); + + for (const dbFolder of dbFolders) { + if (!folders.includes(dbFolder)) { + log(`🗑️ Cartella rimossa dal filesystem: ${dbFolder}`); + await deleteFolderForUser(userName, dbFolder); + } + } + + await ensurePhotoIngestQueue(db); + + let queued = 0; + let skipped = 0; + + + for (const cartella of folders) { + const absCartella = path.join(userDir, cartella); + log(`📁 Controllo cartella: ${cartella}`); + + for await (const file of scanCartella(userName, cartella, absCartella, db)) { + + const previous = await db('photos') + .select('id', 'size_bytes', 'mtimeMs', 'fast_hash', 'path') + .where({ id: file.id }) + .first(); + + const expectedHash = sha256(`${file.stat.size}-${file.stat.mtimeMs}`); + const expectedPath = file.path; + + let needsProcessing = + !previous || + previous.path !== expectedPath || + previous.fast_hash !== expectedHash; + + if (!needsProcessing) { + needsProcessing = !(await thumbnailsExist( + userName, + cartella, + file.relPath + )); + } + + if (needsProcessing) { + await enqueuePhotoIngestJob(db, { + user: userName, + cartella, + relPath: file.relPath, + mtimeMs: file.stat.mtimeMs, + sizeBytes: file.stat.size, + }); + queued++; + } else { + skipped++; + } + + } + } + + + log( + `🟣 Scansione/accodamento terminato per ${userName}: ` + + `${queued} accodati, ${skipped} invariati` + ); + + // Restituisce un array per compatibilità con chi già chiama questa funzione. + // Non contiene risultati elaborati: quelli arriveranno dal worker. + return []; +} + +module.exports = scanPhotosUser; diff --git a/api_v1/scanner/scanUser.js b/api_v1/scanner/scanUser.js new file mode 100644 index 0000000..03a4b18 --- /dev/null +++ b/api_v1/scanner/scanUser.js @@ -0,0 +1,25 @@ +// api_v1/scanner/scanUser.js +const scanPhotosUser = require('./scanPhotosUser'); +const scanActivitiesUser = require('./scanActivitiesUser'); +const { log } = require('./logger'); + +async function scanUser(userName, db) { + log(`🔵 Inizio scan COMPLETO per user=${userName}`); + + // 🔥 SCAN FOTO + const newPhotos = await scanPhotosUser(userName, db); + + // 🔥 SCAN ATTIVITÀ + const newActivities = await scanActivitiesUser(userName, db); + + log(`🟣 Scan COMPLETATO per user=${userName}`); + log(` 📸 Nuove foto: ${newPhotos.length}`); + log(` 🏃‍♂️ Nuove attività: ${newActivities.length}`); + + return { + photos: newPhotos, + activities: newActivities + }; +} + +module.exports = scanUser; diff --git a/api_v1/scanner/thumbs.js b/api_v1/scanner/thumbs.js new file mode 100644 index 0000000..358f0c3 --- /dev/null +++ b/api_v1/scanner/thumbs.js @@ -0,0 +1,288 @@ +// api_v1/scanner/thumbs.js + +const fs = require('fs'); +const sharp = require('sharp'); +const { exec } = require('child_process'); +const path = require('path'); + +/* + * ============================================================ + * CONFIGURAZIONE IMMAGINE CAST + * ============================================================ + */ + +/* + * Se il file originale è JPEG e pesa meno di questa soglia, + * il file _cast.jpg sarà una copia identica dell'originale. + * + * 500 * 1024 = 512000 byte. + */ +const CAST_JPEG_COPY_LIMIT_BYTES = 500 * 1024; + +/* + * Dimensioni massime dell'immagine Cast. + * + * L'immagine viene sempre contenuta dentro questo rettangolo, + * senza crop e senza ingrandimento. + */ +const CAST_MAX_WIDTH = 1920; +const CAST_MAX_HEIGHT = 1080; + +/* + * Qualità JPEG usata quando è necessaria la conversione. + */ +const CAST_JPEG_QUALITY = 85; + + +/* + * Video: crea soltanto AVG e MIN. + */ +function createVideoThumbnail( + videoPath, + thumbMinPath, + thumbAvgPath +) { + return new Promise(resolve => { + const cmd = ` + ffmpeg -y -i "${videoPath}" -ss 00:00:01.000 -vframes 1 "${thumbAvgPath}" && + ffmpeg -y -i "${thumbAvgPath}" -vf "scale=100:-1" "${thumbMinPath}" + `; + + exec(cmd, error => { + if (error) { + console.error( + '[THUMB VIDEO] errore creazione thumbnail', + { + videoPath, + thumbMinPath, + thumbAvgPath, + error: error.message, + } + ); + } else { + console.log( + '[THUMB VIDEO] thumbnails create', + { + videoPath, + thumbMinPath, + thumbAvgPath, + } + ); + } + + /* + * Mantiene il comportamento originale: + * la Promise viene risolta anche in caso di errore. + */ + resolve(); + }); + }); +} + +/* + * Crea la versione CAST dell'immagine. + * + * Regole: + * + * 1. Se l'originale è JPEG e pesa meno di 500 KB: + * copia byte per byte l'originale nel file _cast.jpg. + * + * 2. In tutti gli altri casi: + * converte l'immagine in JPEG qualità 85. + * + * 3. Se l'immagine supera 1920x1080: + * viene ridimensionata per stare dentro 1920x1080. + * + * 4. Se l'immagine è già più piccola: + * mantiene le sue dimensioni, senza ingrandirla. + * + * 5. Non viene mai effettuato crop. + */ +async function createCastImage( + filePath, + finalCastPath +) { + const sourceStat = + await fs.promises.stat(filePath); + + const metadata = + await sharp(filePath).metadata(); + + /* + * metadata.format === "jpeg" riconosce il contenuto reale + * dell'immagine, indipendentemente dall'estensione .jpg o .jpeg. + */ + const sourceIsJpeg = + metadata.format === 'jpeg'; + + const sourceIsUnderCopyLimit = + sourceStat.size < + CAST_JPEG_COPY_LIMIT_BYTES; + + /* + * JPEG sotto 500 KB: + * copia identica dell'originale. + * + * Non viene effettuata: + * + * - ricompressione; + * - rotazione; + * - modifica dei metadati; + * - modifica delle dimensioni. + */ + if ( + sourceIsJpeg && + sourceIsUnderCopyLimit + ) { + await fs.promises.copyFile( + filePath, + finalCastPath + ); + + const castStat = + await fs.promises.stat(finalCastPath); + + console.log( + '[THUMB CAST] originale JPEG copiato', + { + source: filePath, + destination: finalCastPath, + sourceBytes: sourceStat.size, + castBytes: castStat.size, + copyLimitBytes: + CAST_JPEG_COPY_LIMIT_BYTES, + sourceFormat: metadata.format, + sourceWidth: metadata.width || null, + sourceHeight: metadata.height || null, + } + ); + + return; + } + + /* + * Tutti gli altri casi: + * + * - JPEG da almeno 500 KB; + * - PNG; + * - WebP; + * - HEIC/HEIF, se supportato da Sharp; + * - TIFF; + * - altri formati decodificabili da Sharp. + * + * L'immagine viene convertita in JPEG qualità 85. + * + * withoutEnlargement: true garantisce che un'immagine più + * piccola di 1920x1080 mantenga le proprie dimensioni. + */ + await sharp(filePath) + .rotate() + .resize({ + width: CAST_MAX_WIDTH, + height: CAST_MAX_HEIGHT, + fit: 'inside', + withoutEnlargement: true, + fastShrinkOnLoad: true, + }) + .jpeg({ + quality: CAST_JPEG_QUALITY, + progressive: false, + optimizeCoding: true, + }) + .toFile(finalCastPath); + + const castStat = + await fs.promises.stat(finalCastPath); + + const castMetadata = + await sharp(finalCastPath).metadata(); + + console.log( + '[THUMB CAST] immagine convertita', + { + source: filePath, + destination: finalCastPath, + sourceBytes: sourceStat.size, + castBytes: castStat.size, + sourceFormat: + metadata.format || null, + sourceWidth: + metadata.width || null, + sourceHeight: + metadata.height || null, + castFormat: + castMetadata.format || null, + castWidth: + castMetadata.width || null, + castHeight: + castMetadata.height || null, + quality: CAST_JPEG_QUALITY, + maxWidth: CAST_MAX_WIDTH, + maxHeight: CAST_MAX_HEIGHT, + reason: sourceIsJpeg + ? 'JPEG sopra o uguale alla soglia' + : 'formato originale non JPEG', + } + ); +} + +/* + * Foto: crea MIN, AVG e CAST. + */ +async function createThumbnails( + filePath, + thumbMinPath, + thumbAvgPath +) { + try { + +const avgBuffer = await sharp(filePath) + .rotate() + .resize({ + width: 400, + fit: 'inside', + withoutEnlargement: true, + }) + .jpeg({ + quality: 78, + progressive: false, + optimizeCoding: true, + }) + .toBuffer(); + +await Promise.all([ + fs.promises.writeFile(thumbAvgPath, avgBuffer), + + sharp(avgBuffer) + .resize({ + width: 100, + height: 100, + fit: 'inside', + withoutEnlargement: true, + }) + .jpeg({ + quality: 75, + progressive: false, + optimizeCoding: true, + }) + .toFile(thumbMinPath), +]); + + } catch (err) { + console.error( + '[THUMB] errore creazione thumbnails', + { + filePath, + thumbMinPath, + thumbAvgPath, + error: err.message, + stack: err.stack, + } + ); + } +} + +module.exports = { + createVideoThumbnail, + createThumbnails, +}; diff --git a/api_v1/scanner/utils.js b/api_v1/scanner/utils.js new file mode 100644 index 0000000..6db52e9 --- /dev/null +++ b/api_v1/scanner/utils.js @@ -0,0 +1,41 @@ +const path = require('path'); +const crypto = require('crypto'); + +function toPosix(p) { + return p.split(path.sep).join('/'); +} + +function sha256(s) { + return crypto.createHash('sha256').update(s).digest('hex'); +} + +function inferMimeFromExt(ext) { + switch (ext.toLowerCase()) { + case '.jpg': + case '.jpeg': return 'image/jpeg'; + case '.png': return 'image/png'; + case '.webp': return 'image/webp'; + case '.heic': + case '.heif': return 'image/heic'; + case '.mp4': return 'video/mp4'; + case '.mov': return 'video/quicktime'; + case '.m4v': return 'video/x-m4v'; + default: return 'application/octet-stream'; + } +} + +function parseExifDateUtc(s) { + if (!s) return null; + const re = /^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/; + const m = re.exec(s); + if (!m) return null; + const dt = new Date(Date.UTC(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +m[6])); + return dt.toISOString(); +} + +module.exports = { + toPosix, + sha256, + inferMimeFromExt, + parseExifDateUtc +}; diff --git a/api_v1/scanner/video.js b/api_v1/scanner/video.js new file mode 100644 index 0000000..7d428fe --- /dev/null +++ b/api_v1/scanner/video.js @@ -0,0 +1,17 @@ +const { exec } = require('child_process'); + +function probeVideo(videoPath) { + return new Promise((resolve) => { + const cmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`; + exec(cmd, (err, stdout) => { + if (err) return resolve({}); + try { + resolve(JSON.parse(stdout)); + } catch { + resolve({}); + } + }); + }); +} + +module.exports = { probeVideo }; diff --git a/api_v1/scanner/writeJsonAtomic.js b/api_v1/scanner/writeJsonAtomic.js new file mode 100644 index 0000000..2ad43c6 --- /dev/null +++ b/api_v1/scanner/writeJsonAtomic.js @@ -0,0 +1,17 @@ +// api_v1/scanner/writeJsonAtomic.js +const path = require('path'); +const fsp = require('fs/promises'); + +async function writeJsonAtomic(filePath, data) { + const tmpPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random() + .toString(16) + .slice(2)}.tmp`; + + const json = JSON.stringify(data, null, 2); + + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + await fsp.writeFile(tmpPath, json, 'utf8'); + await fsp.rename(tmpPath, filePath); +} + +module.exports = writeJsonAtomic; diff --git a/api_v1/tools.js b/api_v1/tools.js new file mode 100644 index 0000000..13fca1d --- /dev/null +++ b/api_v1/tools.js @@ -0,0 +1,51 @@ +/** + * Required libraries + */ +const bcrypt = require('bcrypt') +const readLine = require('readline') +const async = require('async') + +// Password hash method +const hashPassword = plain => bcrypt.hashSync(plain, 8) + +// Ask user password method +function askPassword(question) { + return new Promise((resolve, reject) => { + const rl = readLine.createInterface({ + input: process.stdin, + output: process.stdout + }) + + rl.question(question, answer => { + rl.close() + resolve(answer) + }) + }) +} + +// Generate hash password method +async function generateHash() { + try { + console.log('**********************************') + console.log('** Password hash script **') + console.log('**********************************') + + const passwordAnswer = await askPassword( + 'Please give me a password to hash: ' + ) + + if (passwordAnswer != '') { + const hashedPassword = hashPassword(passwordAnswer) + const compare = bcrypt.compareSync(passwordAnswer, hashedPassword) + await console.log('Hashed password:', hashedPassword) + await console.log('Valdiation:', compare) + } else { + console.log('You need write something. Script aborted!') + } + } catch (err) { + console.log(err) + return process.exit(1) + } +} + +generateHash() diff --git a/api_v1/userAccess.js b/api_v1/userAccess.js new file mode 100644 index 0000000..4ecb7ac --- /dev/null +++ b/api_v1/userAccess.js @@ -0,0 +1,159 @@ +const fs = require('fs'); +const path = require('path'); + +const usersPath = path.resolve(__dirname, 'users.json'); + +function normalize(value) { + return String(value ?? '').trim(); +} + +function unique(values) { + return [ + ...new Set( + values + .map(normalize) + .filter(Boolean) + ) + ]; +} + +function loadUsers() { + const raw = fs.readFileSync(usersPath, 'utf8'); + const data = JSON.parse(raw); + + if (!data || !Array.isArray(data.users)) { + throw new Error('Formato users.json non valido'); + } + + return data.users; +} + +function findUserByName(userName) { + const requestedName = normalize(userName).toLowerCase(); + + if (!requestedName) { + return null; + } + + return ( + loadUsers().find((user) => { + return normalize(user.name).toLowerCase() === requestedName; + }) || null + ); +} + +function getUserAccess(userName) { + const user = findUserByName(userName); + + if (!user) { + return null; + } + + const devices = Array.isArray(user.devices) + ? unique(user.devices) + : []; + + const usersR = Array.isArray(user.usersR) + ? unique(user.usersR) + : []; + + const usersW = Array.isArray(user.usersW) + ? unique(user.usersW) + : []; + + return { + id: user.id, + name: normalize(user.name), + devices, + usersR, + usersW, + + // Contenuti leggibili nella galleria + readableSources: unique([ + ...usersR, + ...devices + ]), + + // Contenuti modificabili + writableSources: unique([ + ...usersW, + ...devices + ]) + }; +} + +function getReadableSources(userName) { + const access = getUserAccess(userName); + + return access + ? access.readableSources + : []; +} + +function getWritableSources(userName) { + const access = getUserAccess(userName); + + return access + ? access.writableSources + : []; +} + +function filterReadableSources(userName, requestedSources) { + const readableSources = getReadableSources(userName); + + if (!requestedSources) { + return readableSources; + } + + const requested = Array.isArray(requestedSources) + ? requestedSources + : [requestedSources]; + + const readableMap = new Map( + readableSources.map((source) => [ + source.toLowerCase(), + source + ]) + ); + + return unique(requested) + .map((source) => { + return readableMap.get(source.toLowerCase()); + }) + .filter(Boolean); +} + +function canRead(userName, sourceName) { + const source = normalize(sourceName).toLowerCase(); + + if (!source) { + return false; + } + + return getReadableSources(userName).some((allowedSource) => { + return allowedSource.toLowerCase() === source; + }); +} + +function canWrite(userName, sourceName) { + const source = normalize(sourceName).toLowerCase(); + + if (!source) { + return false; + } + + return getWritableSources(userName).some((allowedSource) => { + return allowedSource.toLowerCase() === source; + }); +} + +module.exports = { + loadUsers, + findUserByName, + getUserAccess, + getReadableSources, + getWritableSources, + filterReadableSources, + canRead, + canWrite +}; \ No newline at end of file diff --git a/api_v1/users.json b/api_v1/users.json new file mode 100644 index 0000000..b347f95 --- /dev/null +++ b/api_v1/users.json @@ -0,0 +1,31 @@ +{ + "users": [ + { + "id": 1, + "name": "Admin", + "email": "admin@gmail.com", + "password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3.", + "devices": [], + "usersR": ["Common", "Fabio", "Jessica", "Fabio13TPro", "Fabio11TPro"], + "usersW": ["Common", "Fabio", "Jessica", "Fabio13TPro", "Fabio11TPro"] + }, + { + "id": 2, + "name": "Fabio", + "email": "fabio@gmail.com", + "password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3.", + "devices": ["Fabio13TPro", "Fabio11TPro"], + "usersR": ["Common", "Fabio"], + "usersW": ["Fabio"] + }, + { + "id": 3, + "name": "Jessica", + "email": "jessie@libero.it", + "password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3.", + "devices": [], + "usersR": ["Common", "Jessica"], + "usersW": ["Jessica"] + } + ] +} diff --git a/bin/face_scan b/bin/face_scan new file mode 100755 index 0000000..008171e Binary files /dev/null and b/bin/face_scan differ diff --git a/clear.sh b/clear.sh new file mode 100755 index 0000000..c867240 --- /dev/null +++ b/clear.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" + +echo "=== RESET s175js ===" +echo "Directory: $(pwd)" + +# Verifica i PID registrati nei lock prima di rimuoverli. +for file in face-scan-daemon.lock prototype-worker-daemon.lock geocode-daemon.lock; do + [ -f "$file" ] || continue + + pid="" + if [[ "$file" == "prototype-worker-daemon.lock" ]]; then + pid=$(tr -cd '0-9' < "$file") + else + pid=$(node -e ' + try { + const x = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + console.log(x.pid || ""); + } catch {} + ' "$file") + fi + + if [[ -n "$pid" && -d "/proc/$pid" ]]; then + echo "ERRORE: $file segnala il PID attivo $pid. Reset annullato." + exit 1 + fi +done + +# Backup del database prima della cancellazione. +if [ -f api_v1/database.sqlite ]; then + backup="api_v1/database.sqlite.pre-clear-$(date +%Y%m%d-%H%M%S)" + cp api_v1/database.sqlite "$backup" + echo "Backup creato: $backup" +fi + +rm -f \ + face-scan-daemon.lock \ + prototype-worker-daemon.lock \ + geocode-daemon.lock \ + api_v1/database.sqlite \ + api_v1/database.sqlite-wal \ + api_v1/database.sqlite-shm + +echo "=== INIZIALIZZAZIONE DATABASE ===" +node db/init.js + +echo "=== CONTROLLO INTEGRITÀ ===" +sqlite3 api_v1/database.sqlite "PRAGMA integrity_check;" + +echo "=== RESET COMPLETATO ===" diff --git a/db/activities.js b/db/activities.js new file mode 100644 index 0000000..132a830 --- /dev/null +++ b/db/activities.js @@ -0,0 +1,209 @@ +// db/activities.js +const knex = require('./knex'); + +/* ============================================================ + INSERT OR UPDATE ACTIVITY + ============================================================ */ +async function insertOrUpdateActivity(row) { + const exists = await knex('activities').where({ id: row.id }).first(); + + if (!exists) { + await knex('activities').insert({ + id: row.id, + user: row.user, + cartella: row.cartella, + + file_name: row.file_name, + file_extension: row.file_extension, + format: row.format, + display_name: row.display_name, + route_name: row.route_name, + title: row.title, + description: row.description, + notes: row.notes, + sport: row.sport, + activity_type: row.activity_type, + device_manufacturer: row.device_manufacturer, + device_model: row.device_model, + source_app: row.source_app, + + start_time_millis: row.start_time_millis, + end_time_millis: row.end_time_millis, + duration_millis: row.duration_millis, + + distance_meters: row.distance_meters, + elevation_gain_meters: row.elevation_gain_meters, + elevation_loss_meters: row.elevation_loss_meters, + + min_elevation_meters: row.min_elevation_meters, + max_elevation_meters: row.max_elevation_meters, + + file_distance_meters: row.file_distance_meters, + file_elevation_gain_meters: row.file_elevation_gain_meters, + + computed_distance_meters: row.computed_distance_meters, + computed_elevation_gain_meters: row.computed_elevation_gain_meters, + + average_speed_mps: row.average_speed_mps, + max_speed_mps: row.max_speed_mps, + average_pace_sec_per_km: row.average_pace_sec_per_km, + + average_heart_rate: row.average_heart_rate, + max_heart_rate: row.max_heart_rate, + average_cadence: row.average_cadence, + max_cadence: row.max_cadence, + + calories: row.calories, + power_avg: row.power_avg, + power_max: row.power_max, + + point_count: row.point_count, + segment_count: row.segment_count, + lap_count: row.lap_count, + + min_latitude: row.min_latitude, + max_latitude: row.max_latitude, + min_longitude: row.min_longitude, + max_longitude: row.max_longitude, + center_latitude: row.center_latitude, + center_longitude: row.center_longitude, + + size_bytes: row.size_bytes, + last_modified_millis: row.last_modified_millis, + date_added_millis: row.date_added_millis, + date_modified_millis: row.date_modified_millis, + last_scan_millis: row.last_scan_millis, + + is_favorite: row.is_favorite, + is_hidden: row.is_hidden, + rating: row.rating, + + tags: row.tags ? JSON.stringify(row.tags) : null, + scan_status: row.scan_status, + scan_error: row.scan_error, + diagnostics: row.diagnostics ? JSON.stringify(row.diagnostics) : null, + location: row.location ? JSON.stringify(row.location) : null, + + created_at: knex.fn.now(), + updated_at: knex.fn.now() + }); + } else { + await knex('activities').where({ id: row.id }).update({ + // aggiorna solo i campi che possono cambiare + file_name: row.file_name || exists.file_name, + file_extension: row.file_extension || exists.file_extension, + format: row.format || exists.format, + display_name: row.display_name || exists.display_name, + route_name: row.route_name || exists.route_name, + title: row.title || exists.title, + description: row.description || exists.description, + notes: row.notes || exists.notes, + sport: row.sport || exists.sport, + activity_type: row.activity_type || exists.activity_type, + device_manufacturer: row.device_manufacturer || exists.device_manufacturer, + device_model: row.device_model || exists.device_model, + source_app: row.source_app || exists.source_app, + + start_time_millis: row.start_time_millis || exists.start_time_millis, + end_time_millis: row.end_time_millis || exists.end_time_millis, + duration_millis: row.duration_millis || exists.duration_millis, + + distance_meters: row.distance_meters || exists.distance_meters, + elevation_gain_meters: row.elevation_gain_meters || exists.elevation_gain_meters, + elevation_loss_meters: row.elevation_loss_meters || exists.elevation_loss_meters, + + min_elevation_meters: row.min_elevation_meters || exists.min_elevation_meters, + max_elevation_meters: row.max_elevation_meters || exists.max_elevation_meters, + + file_distance_meters: row.file_distance_meters || exists.file_distance_meters, + file_elevation_gain_meters: row.file_elevation_gain_meters || exists.file_elevation_gain_meters, + + computed_distance_meters: row.computed_distance_meters || exists.computed_distance_meters, + computed_elevation_gain_meters: row.computed_elevation_gain_meters || exists.computed_elevation_gain_meters, + + average_speed_mps: row.average_speed_mps || exists.average_speed_mps, + max_speed_mps: row.max_speed_mps || exists.max_speed_mps, + average_pace_sec_per_km: row.average_pace_sec_per_km || exists.average_pace_sec_per_km, + + average_heart_rate: row.average_heart_rate || exists.average_heart_rate, + max_heart_rate: row.max_heart_rate || exists.max_heart_rate, + average_cadence: row.average_cadence || exists.average_cadence, + max_cadence: row.max_cadence || exists.max_cadence, + + calories: row.calories || exists.calories, + power_avg: row.power_avg || exists.power_avg, + power_max: row.power_max || exists.power_max, + + point_count: row.point_count || exists.point_count, + segment_count: row.segment_count || exists.segment_count, + lap_count: row.lap_count || exists.lap_count, + + min_latitude: row.min_latitude || exists.min_latitude, + max_latitude: row.max_latitude || exists.max_latitude, + min_longitude: row.min_longitude || exists.min_longitude, + max_longitude: row.max_longitude || exists.max_longitude, + center_latitude: row.center_latitude || exists.center_latitude, + center_longitude: row.center_longitude || exists.center_longitude, + + size_bytes: row.size_bytes || exists.size_bytes, + last_modified_millis: row.last_modified_millis || exists.last_modified_millis, + date_added_millis: row.date_added_millis || exists.date_added_millis, + date_modified_millis: row.date_modified_millis || exists.date_modified_millis, + last_scan_millis: row.last_scan_millis || exists.last_scan_millis, + + is_favorite: row.is_favorite ?? exists.is_favorite, + is_hidden: row.is_hidden ?? exists.is_hidden, + rating: row.rating ?? exists.rating, + + tags: row.tags ? JSON.stringify(row.tags) : exists.tags, + scan_status: row.scan_status || exists.scan_status, + scan_error: row.scan_error || exists.scan_error, + diagnostics: row.diagnostics ? JSON.stringify(row.diagnostics) : exists.diagnostics, + location: row.location ? JSON.stringify(row.location) : exists.location, + + updated_at: knex.fn.now() + }); + } +} + +/* ============================================================ + HARD DELETE + ============================================================ */ +async function markActivityDeletedHard(id, user) { + await knex('activities').where({ id, user }).del(); + await knex('deleted_hard_activities') + .insert({ id, user, deleted_at: knex.fn.now() }) + .catch(() => {}); +} + +/* ============================================================ + CHANGES SINCE + ============================================================ */ +async function getActivitiesSince(user, sinceIso, limit = 1000) { + return knex('activities') + .where({ user }) + .andWhere('updated_at', '>', sinceIso) + .orderBy('updated_at', 'asc') + .limit(limit) + .select('*'); +} + +/* ============================================================ + DELETED HARD SINCE + ============================================================ */ +async function getDeletedHardActivitiesSince(user, sinceIso) { + return knex('deleted_hard_activities') + .where({ user }) + .andWhere('deleted_at', '>', sinceIso) + .select('id', 'deleted_at'); +} + +/* ============================================================ + EXPORT + ============================================================ */ +module.exports = { + insertOrUpdateActivity, + markActivityDeletedHard, + getActivitiesSince, + getDeletedHardActivitiesSince +}; diff --git a/db/dbWs.js b/db/dbWs.js new file mode 100644 index 0000000..a90074d --- /dev/null +++ b/db/dbWs.js @@ -0,0 +1,142 @@ +// db/dbWs.js +console.log(">>> dbWs.js CARICATO:", __filename); +const knex = require("./knex"); + +// =============================== +// SESSIONI +// =============================== + +async function upsertSession(session_id, user, device_id) { + const now = Date.now(); + const existing = await knex("ws_sessions") + .where({ session_id, device_id }) + .first(); + + if (!existing) { + await knex("ws_sessions").insert({ + session_id, + user, + device_id, + connected_at: now, + last_ack: now, + last_sync: null, + need_full_sync: false + }); + } else { + await knex("ws_sessions") + .where({ session_id, device_id }) + .update({ + user, + connected_at: now, + last_ack: now + }); + } +} + +async function getDevicesForSession(session_id) { + const rows = await knex('ws_sessions') + .where({ session_id }) + .select('device_id'); + + return rows.map(r => r.device_id); +} + +async function getSession(session_id, device_id) { + return knex("ws_sessions") + .where({ session_id, device_id }) + .first(); +} + +async function updateSessionAck(session_id, device_id) { + return knex("ws_sessions") + .where({ session_id, device_id }) + .update({ last_ack: Date.now() }); +} + +async function setSessionNeedFullSync(session_id, device_id, value) { + return knex("ws_sessions") + .where({ session_id, device_id }) + .update({ need_full_sync: value }); +} + +async function clearNeedFullSync(session_id, device_id) { + return knex("ws_sessions") + .where({ session_id, device_id }) + .update({ need_full_sync: false }); +} + +async function getSessionsByUser(user) { + return knex("ws_sessions").where({ user }); +} + +async function deleteSession(session_id, device_id) { + await knex("ws_sessions").where({ session_id, device_id }).del(); + await knex("pending_events").where({ session_id, device_id }).del(); +} + +// =============================== +// PENDING EVENTS (nuovo modello) +// =============================== + +async function insertPendingEvent({ user_id, session_id, device_id, event_id, payload, created_at }) { + return knex("pending_events").insert({ + user_id, + session_id, + device_id, + event_id, + payload, + created_at + }); +} + +async function deletePendingEvent({ user_id, session_id, device_id, event_id }) { + return knex("pending_events") + .where({ user_id, session_id, device_id, event_id }) + .delete(); +} + +async function getPendingEvents(user_id, session_id, device_id) { + return knex("pending_events") + .where({ user_id, session_id, device_id }) + .orderBy("created_at", "asc"); +} + +async function deleteAllSessions() { + console.log(">>> deleteAllSessions() – elimino tutte le sessioni WS"); + return knex("ws_sessions").del(); +} + +async function deleteAllPendingEvents() { + console.log(">>> deleteAllPendingEvents() – elimino tutti i pending events"); + return knex("pending_events").del(); +} + +async function setLastSync(session_id, device_id, timestamp = null) { + const ts = timestamp || Date.now(); + return knex("ws_sessions") + .where({ session_id, device_id }) + .update({ last_sync: ts }); +} + +// =============================== +// EXPORT +// =============================== + +module.exports = { + upsertSession, + getSession, + updateSessionAck, + setSessionNeedFullSync, + clearNeedFullSync, + deleteSession, + getSessionsByUser, + + insertPendingEvent, + deletePendingEvent, + getPendingEvents, + getDevicesForSession, + + setLastSync, + deleteAllSessions, + deleteAllPendingEvents +}; diff --git a/db/faceScanAutomation.js b/db/faceScanAutomation.js new file mode 100644 index 0000000..17d6501 --- /dev/null +++ b/db/faceScanAutomation.js @@ -0,0 +1,219 @@ +async function ensureFaceScanAutomation(db) { + const triggerNames = [ + 'photos_face_enqueue_insert', + 'photos_face_enqueue_update', + 'photos_face_cleanup_update', + 'photos_face_cleanup_delete', + ]; + + for (const triggerName of triggerNames) { + await db.raw( + `DROP TRIGGER IF EXISTS ${triggerName}`, + ); + } + + await db.raw(` + CREATE TRIGGER photos_face_enqueue_insert + AFTER INSERT ON photos + WHEN + NEW.deleted_at IS NULL + AND NEW.path IS NOT NULL + AND NEW.path != '' + AND ( + NEW.mime_type IS NULL + OR NEW.mime_type LIKE 'image/%' + ) + BEGIN + INSERT INTO face_scan_jobs ( + photo_id, + status, + attempts, + rescan_requested, + created_at, + updated_at + ) + VALUES ( + NEW.id, + 'pending', + 0, + 0, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + ON CONFLICT(photo_id) DO UPDATE SET + status = CASE + WHEN face_scan_jobs.status = 'processing' + THEN face_scan_jobs.status + ELSE 'pending' + END, + + rescan_requested = CASE + WHEN face_scan_jobs.status = 'processing' + THEN 1 + ELSE 0 + END, + + faces_detected = CASE + WHEN face_scan_jobs.status = 'processing' + THEN face_scan_jobs.faces_detected + ELSE NULL + END, + + execution_ms = CASE + WHEN face_scan_jobs.status = 'processing' + THEN face_scan_jobs.execution_ms + ELSE NULL + END, + + applied_rotation = CASE + WHEN face_scan_jobs.status = 'processing' + THEN face_scan_jobs.applied_rotation + ELSE NULL + END, + + last_error = NULL, + + started_at = CASE + WHEN face_scan_jobs.status = 'processing' + THEN face_scan_jobs.started_at + ELSE NULL + END, + + completed_at = NULL, + updated_at = CURRENT_TIMESTAMP; + END + `); + + await db.raw(` + CREATE TRIGGER photos_face_enqueue_update + AFTER UPDATE OF + path, + fast_hash, + mtimeMs, + size_bytes, + rotation, + mime_type, + deleted_at + ON photos + WHEN + NEW.deleted_at IS NULL + AND NEW.path IS NOT NULL + AND NEW.path != '' + AND ( + NEW.mime_type IS NULL + OR NEW.mime_type LIKE 'image/%' + ) + AND ( + OLD.path IS NOT NEW.path + OR OLD.fast_hash IS NOT NEW.fast_hash + OR OLD.mtimeMs IS NOT NEW.mtimeMs + OR OLD.size_bytes IS NOT NEW.size_bytes + OR OLD.rotation IS NOT NEW.rotation + OR OLD.mime_type IS NOT NEW.mime_type + OR OLD.deleted_at IS NOT NEW.deleted_at + ) + BEGIN + INSERT INTO face_scan_jobs ( + photo_id, + status, + attempts, + rescan_requested, + created_at, + updated_at + ) + VALUES ( + NEW.id, + 'pending', + 0, + 0, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + ON CONFLICT(photo_id) DO UPDATE SET + status = CASE + WHEN face_scan_jobs.status = 'processing' + THEN face_scan_jobs.status + ELSE 'pending' + END, + + rescan_requested = CASE + WHEN face_scan_jobs.status = 'processing' + THEN 1 + ELSE 0 + END, + + faces_detected = CASE + WHEN face_scan_jobs.status = 'processing' + THEN face_scan_jobs.faces_detected + ELSE NULL + END, + + execution_ms = CASE + WHEN face_scan_jobs.status = 'processing' + THEN face_scan_jobs.execution_ms + ELSE NULL + END, + + applied_rotation = CASE + WHEN face_scan_jobs.status = 'processing' + THEN face_scan_jobs.applied_rotation + ELSE NULL + END, + + last_error = NULL, + + started_at = CASE + WHEN face_scan_jobs.status = 'processing' + THEN face_scan_jobs.started_at + ELSE NULL + END, + + completed_at = NULL, + updated_at = CURRENT_TIMESTAMP; + END + `); + + await db.raw(` + CREATE TRIGGER photos_face_cleanup_update + AFTER UPDATE OF + path, + mime_type, + deleted_at + ON photos + WHEN + NEW.deleted_at IS NOT NULL + OR NEW.path IS NULL + OR NEW.path = '' + OR ( + NEW.mime_type IS NOT NULL + AND NEW.mime_type NOT LIKE 'image/%' + ) + BEGIN + DELETE FROM faces + WHERE photo_id = NEW.id; + + DELETE FROM face_scan_jobs + WHERE photo_id = NEW.id; + END + `); + + await db.raw(` + CREATE TRIGGER photos_face_cleanup_delete + AFTER DELETE ON photos + BEGIN + DELETE FROM faces + WHERE photo_id = OLD.id; + + DELETE FROM face_scan_jobs + WHERE photo_id = OLD.id; + END + `); + + console.log( + '✔ trigger automatici scansione volti creati', + ); +} + +module.exports = { + ensureFaceScanAutomation, +}; diff --git a/db/init.j b/db/init.j new file mode 100644 index 0000000..198e230 --- /dev/null +++ b/db/init.j @@ -0,0 +1,547 @@ +// db/init.js +const db = require('./knex'); + +/* ============================================================ + FOTO + ============================================================ */ +async function ensurePhotos() { + const exists = await db.schema.hasTable('photos'); + if (!exists) { + await db.schema.createTable('photos', (t) => { + t.string('id').primary(); + t.string('user'); + t.string('cartella'); + t.string('name'); + t.string('path'); + t.text('extPath').notNullable().defaultTo(''); + t.text('extMedia').notNullable().defaultTo(''); + t.integer('hybrid').notNullable().defaultTo(0); + t.string('thub1'); + t.string('thub2'); + t.string('cast'); + t.string('mime_type'); + t.integer('width'); + t.integer('height'); + t.integer('rotation'); + t.integer('size_bytes'); + t.integer('mtimeMs'); + t.integer('duration_ms'); + t.string('taken_at'); + t.string('data'); + t.float('lat'); + t.float('lon'); + t.float('alt'); + t.text('location'); + t.string('_indexHash'); + t.string('fast_hash'); + t.datetime('created_at').defaultTo(db.fn.now()); + t.datetime('updated_at').defaultTo(db.fn.now()); + t.datetime('deleted_at').nullable(); + }); + console.log("✔ photos creata"); + } else { + const cols = [ + 'thub1','thub2','fast_hash','created_at','updated_at','deleted_at' + ]; + for (const col of cols) { + const existsCol = await db.schema.hasColumn('photos', col); + if (!existsCol) { + await db.schema.alterTable('photos', (t) => { + if (col === 'created_at' || col === 'updated_at') + t.datetime(col).defaultTo(db.fn.now()); + else if (col === 'deleted_at') + t.datetime(col).nullable(); + else + t.string(col); + }); + console.log(`✔ aggiunta colonna photos.${col}`); + } + } + } +} + +/* ============================================================ + FOTO — deleted_hard + ============================================================ */ +async function ensureDeletedHard() { + const exists = await db.schema.hasTable('deleted_hard'); + if (!exists) { + await db.schema.createTable('deleted_hard', (t) => { + t.string('id').primary(); + t.string('user'); + t.datetime('deleted_at').defaultTo(db.fn.now()); + }); + console.log("✔ deleted_hard creata"); + } +} + +/* ============================================================ + WS + ============================================================ */ +async function ensureWsSessions() { + const exists = await db.schema.hasTable('ws_sessions'); + if (!exists) { + await db.schema.createTable('ws_sessions', (t) => { + t.string('session_id').notNullable(); + t.string('device_id').notNullable(); + t.string('user').notNullable(); + t.integer('connected_at').notNullable(); + t.integer('last_ack').notNullable(); + t.integer('last_sync').nullable(); + t.boolean('need_full_sync').notNullable().defaultTo(false); + + t.primary(['session_id', 'device_id']); + }); + console.log("✔ ws_sessions creata"); + } else { + const cols = [ + { name: 'device_id', type: 'string' }, + { name: 'last_sync', type: 'integer' } + ]; + + for (const c of cols) { + const existsCol = await db.schema.hasColumn('ws_sessions', c.name); + if (!existsCol) { + await db.schema.alterTable('ws_sessions', (t) => { + if (c.type === 'string') t.string(c.name).notNullable().defaultTo('default'); + if (c.type === 'integer') t.integer(c.name).nullable(); + }); + console.log(`✔ aggiunta colonna ws_sessions.${c.name}`); + } + } + } +} + +async function ensureWsPending() { + const exists = await db.schema.hasTable('ws_pending_events'); + if (!exists) { + await db.schema.createTable('ws_pending_events', (t) => { + t.string('event_id').primary(); + t.string('session_id').notNullable(); + t.text('payload').notNullable(); + t.integer('sent_at').notNullable(); + t.integer('retries').notNullable().defaultTo(0); + }); + console.log("✔ ws_pending_events creata"); + } +} + +async function ensurePendingEvents() { + const exists = await db.schema.hasTable('pending_events'); + if (!exists) { + await db.schema.createTable('pending_events', (t) => { + t.increments('id').primary(); + t.string('user_id').notNullable(); + t.string('session_id').notNullable(); + t.string('device_id').notNullable(); + t.string('event_id').notNullable(); + t.text('payload').notNullable(); + t.integer('created_at').notNullable(); + + t.unique(['user_id', 'session_id', 'device_id', 'event_id']); + }); + console.log("✔ pending_events creata"); + } +} + +/* ============================================================ + 🏃 ATTIVITÀ — nuova tabella + ============================================================ */ +async function ensureActivities() { + const exists = await db.schema.hasTable('activities'); + if (!exists) { + await db.schema.createTable('activities', (t) => { + t.string('id').primary(); + t.string('user'); + t.string('cartella'); + + t.string('file_name'); + t.string('file_extension'); + t.string('format'); + t.string('display_name'); + t.string('route_name'); + t.string('title'); + t.string('description'); + t.string('notes'); + t.string('sport'); + t.string('activity_type'); + t.string('device_manufacturer'); + t.string('device_model'); + t.string('source_app'); + + t.bigInteger('start_time_millis'); + t.bigInteger('end_time_millis'); + t.bigInteger('duration_millis'); + + t.float('distance_meters'); + t.float('elevation_gain_meters'); + t.float('elevation_loss_meters'); + + t.float('min_elevation_meters'); + t.float('max_elevation_meters'); + + t.float('file_distance_meters'); + t.float('file_elevation_gain_meters'); + + t.float('computed_distance_meters'); + t.float('computed_elevation_gain_meters'); + + t.float('average_speed_mps'); + t.float('max_speed_mps'); + t.float('average_pace_sec_per_km'); + + t.integer('average_heart_rate'); + t.integer('max_heart_rate'); + t.integer('average_cadence'); + t.integer('max_cadence'); + + t.integer('calories'); + t.integer('power_avg'); + t.integer('power_max'); + + t.integer('point_count'); + t.integer('segment_count'); + t.integer('lap_count'); + + t.float('min_latitude'); + t.float('max_latitude'); + t.float('min_longitude'); + t.float('max_longitude'); + t.float('center_latitude'); + t.float('center_longitude'); + + t.bigInteger('size_bytes'); + t.bigInteger('last_modified_millis'); + t.bigInteger('date_added_millis'); + t.bigInteger('date_modified_millis'); + t.bigInteger('last_scan_millis'); + + t.boolean('is_favorite'); + t.boolean('is_hidden'); + t.integer('rating'); + + t.text('tags'); + t.text('track_preview'); + t.string('scan_status'); + t.text('scan_error'); + t.string('path'); + t.text('extPath').notNullable().defaultTo(''); + t.integer('hybrid').notNullable().defaultTo(0); + t.text('diagnostics'); + t.text('location'); + t.string('fast_hash'); + t.datetime('deleted_at').nullable(); + t.datetime('created_at').defaultTo(db.fn.now()); + t.datetime('updated_at').defaultTo(db.fn.now()); + }); + console.log("✔ activities creata"); + } +} + +/* ============================================================ + ATTIVITÀ — deleted_hard + ============================================================ */ +async function ensureDeletedHardActivities() { + const exists = await db.schema.hasTable('deleted_hard_activities'); + if (!exists) { + await db.schema.createTable('deleted_hard_activities', (t) => { + t.string('id').primary(); + t.string('user'); + t.datetime('deleted_at').defaultTo(db.fn.now()); + }); + console.log("✔ deleted_hard_activities creata"); + } +} + +/* ============================================================ + ATTIVITÀ — sync_done + ============================================================ */ +async function ensureSyncDoneActivities() { + const exists = await db.schema.hasTable('sync_done_activities'); + if (!exists) { + await db.schema.createTable('sync_done_activities', (t) => { + t.string('session_id'); + t.string('device_id'); + t.bigInteger('last_sync'); + }); + console.log("✔ sync_done_activities creata"); + } +} + +/* ============================================================ + VOLTI + ============================================================ */ +async function ensureFaces() { + const exists = await db.schema.hasTable('faces'); + + if (!exists) { + await db.schema.createTable('faces', (t) => { + // ID univoco del volto + t.string('id').primary(); + + // Foto da cui è stato estratto + t.string('photo_id').notNullable(); + t.integer('face_index').notNullable(); + + // Bounding box nel frame originale + t.float('x').notNullable(); + t.float('y').notNullable(); + t.float('width').notNullable(); + t.float('height').notNullable(); + + // Confidence del detector SCRFD + t.float('score').notNullable(); + + // 5 landmark × 2 coordinate + t.float('landmark_0_x'); + t.float('landmark_0_y'); + + t.float('landmark_1_x'); + t.float('landmark_1_y'); + + t.float('landmark_2_x'); + t.float('landmark_2_y'); + + t.float('landmark_3_x'); + t.float('landmark_3_y'); + + t.float('landmark_4_x'); + t.float('landmark_4_y'); + + // 128 × float32 = 512 byte + t.binary('embedding').notNullable(); + + // Risultato HDBSCAN. + // NULL = non ancora clusterizzato. + t.string('cluster_id').nullable(); + + // Persona assegnata dall'utente. + // NULL = identità non ancora assegnata. + t.string('person_id').nullable(); + + // Stato applicativo del volto. + // detected / clustered / assigned + t.string('status').notNullable().defaultTo('detected'); + + t.datetime('created_at').defaultTo(db.fn.now()); + t.datetime('updated_at').defaultTo(db.fn.now()); + + // Indici principali + t.index(['photo_id']); + t.index(['cluster_id']); + t.index(['person_id']); + t.index(['status']); + + t.unique(['photo_id', 'face_index']); + // Evita due record per lo stesso volto nella stessa foto + // solo se il nostro scanner assegnerà un face_index. + }); + + console.log("✔ faces creata"); + } +} + + +/* ============================================================ + CODA SCANSIONE VOLTI + ============================================================ */ +async function ensureFaceScanJobs() { + const exists = + await db.schema.hasTable('face_scan_jobs'); + + if (!exists) { + await db.schema.createTable( + 'face_scan_jobs', + (t) => { + t.string('photo_id').primary(); + + // pending / processing / done / error + t.string('status') + .notNullable() + .defaultTo('pending'); + + t.integer('attempts') + .notNullable() + .defaultTo(0); + + t.integer('rescan_requested') + .notNullable() + .defaultTo(0); + + t.integer('faces_detected').nullable(); + t.integer('execution_ms').nullable(); + t.integer('applied_rotation').nullable(); + + t.text('last_error').nullable(); + + t.datetime('started_at').nullable(); + t.datetime('completed_at').nullable(); + + t.datetime('created_at') + .defaultTo(db.fn.now()); + + t.datetime('updated_at') + .defaultTo(db.fn.now()); + + t.index(['status']); + t.index(['updated_at']); + }, + ); + + console.log("✔ face_scan_jobs creata"); + } else { + const hasRescanRequested = + await db.schema.hasColumn( + 'face_scan_jobs', + 'rescan_requested', + ); + + if (!hasRescanRequested) { + await db.schema.alterTable( + 'face_scan_jobs', + (t) => { + t.integer('rescan_requested') + .notNullable() + .defaultTo(0); + }, + ); + + console.log( + "✔ aggiunta face_scan_jobs.rescan_requested", + ); + } + } +} + + + +/* ============================================================ + CLUSTER HDBSCAN + ============================================================ */ +async function ensureFaceClusters() { + const exists = await db.schema.hasTable('face_clusters'); + + if (!exists) { + await db.schema.createTable('face_clusters', (t) => { + // ID persistente del cluster + t.string('id').primary(); + + // Identifica una specifica esecuzione di HDBSCAN. + // Esempio: "initial-2026-09-23T04:20:00" + t.string('run_id').notNullable(); + + // Label numerica prodotta da HDBSCAN. + // Può essere -1 per noise. + t.integer('label').notNullable(); + + // Numero di facce nel cluster + t.integer('size').notNullable().defaultTo(0); + + // Parametri principali usati per generare il cluster + t.integer('min_samples'); + t.integer('min_cluster_size'); + + // NULL se il cluster non è ancora stato + // associato ad una persona. + t.string('person_id').nullable(); + + t.datetime('created_at').defaultTo(db.fn.now()); + t.datetime('updated_at').defaultTo(db.fn.now()); + + t.index(['run_id']); + t.index(['person_id']); + + // Un label può comparire una sola volta + // all'interno dello stesso run. + t.unique(['run_id', 'label']); + }); + + console.log("✔ face_clusters creata"); + } +} + + +/* ============================================================ + PERSONE + ============================================================ */ +async function ensurePersons() { + const exists = await db.schema.hasTable('persons'); + + if (!exists) { + await db.schema.createTable('persons', (t) => { + // ID stabile dell'identità applicativa + t.string('id').primary(); + + // Utente proprietario della libreria + t.string('user').nullable(); + + // Nome assegnato dall'utente + t.string('name').notNullable(); + + t.datetime('created_at').defaultTo(db.fn.now()); + t.datetime('updated_at').defaultTo(db.fn.now()); + + t.index(['user']); + t.index(['name']); + }); + + console.log("✔ persons creata"); + } +} + +/* ============================================================ + INIT + ============================================================ */ +/* async function init() { + try { + console.log("🔧 init DB..."); + await ensurePhotos(); + await ensureDeletedHard(); + + await ensureActivities(); + await ensureDeletedHardActivities(); + await ensureSyncDoneActivities(); + + await ensureWsSessions(); + await ensureWsPending(); + await ensurePendingEvents(); + + console.log("✅ init completato"); + process.exit(0); + } catch (err) { + console.error("❌ errore init:", err); + process.exit(1); + } +} +*/ +async function init() { + try { + console.log("🔧 init DB..."); + + await ensurePhotos(); + await ensureDeletedHard(); + + await ensureActivities(); + await ensureDeletedHardActivities(); + await ensureSyncDoneActivities(); + + await ensureWsSessions(); + await ensureWsPending(); + await ensurePendingEvents(); + + // Face recognition + await ensureFaces(); + await ensureFaceScanJobs(); + await ensureFaceClusters(); + await ensurePersons(); + + console.log("✅ init completato"); + process.exit(0); + } catch (err) { + console.error("❌ errore init:", err); + process.exit(1); + } +} + + +init(); diff --git a/db/init.js b/db/init.js new file mode 100644 index 0000000..5cac81e --- /dev/null +++ b/db/init.js @@ -0,0 +1,1325 @@ +// db/init.js +const db = require('./knex'); +const { + ensureFaceScanAutomation, +} = require('./faceScanAutomation'); +const { + ensureServerEventOutbox, +} = require('./serverEventOutbox'); + +/* ============================================================ + FOTO + ============================================================ */ +async function ensurePhotos() { + const exists = await db.schema.hasTable('photos'); + if (!exists) { + await db.schema.createTable('photos', (t) => { + t.string('id').primary(); + t.string('user'); + t.string('cartella'); + t.string('name'); + t.string('path'); + t.text('extPath').notNullable().defaultTo(''); + t.text('extMedia').notNullable().defaultTo(''); + t.integer('hybrid').notNullable().defaultTo(0); + t.string('thub1'); + t.string('thub2'); + t.string('cast'); + t.string('mime_type'); + t.integer('width'); + t.integer('height'); + t.integer('rotation'); + t.integer('size_bytes'); + t.integer('mtimeMs'); + t.integer('duration_ms'); + t.string('taken_at'); + t.string('data'); + t.float('lat'); + t.float('lon'); + t.float('alt'); + t.text('location'); + t.string('_indexHash'); + t.string('fast_hash'); + t.datetime('created_at').defaultTo(db.fn.now()); + t.datetime('updated_at').defaultTo(db.fn.now()); + t.datetime('deleted_at').nullable(); + }); + console.log("✔ photos creata"); + } else { + const cols = [ + 'thub1','thub2','fast_hash','created_at','updated_at','deleted_at' + ]; + for (const col of cols) { + const existsCol = await db.schema.hasColumn('photos', col); + if (!existsCol) { + await db.schema.alterTable('photos', (t) => { + if (col === 'created_at' || col === 'updated_at') + t.datetime(col).defaultTo(db.fn.now()); + else if (col === 'deleted_at') + t.datetime(col).nullable(); + else + t.string(col); + }); + console.log(`✔ aggiunta colonna photos.${col}`); + } + } + } +} + +/* ============================================================ + FOTO — deleted_hard + ============================================================ */ +async function ensureDeletedHard() { + const exists = await db.schema.hasTable('deleted_hard'); + if (!exists) { + await db.schema.createTable('deleted_hard', (t) => { + t.string('id').primary(); + t.string('user'); + t.datetime('deleted_at').defaultTo(db.fn.now()); + }); + console.log("✔ deleted_hard creata"); + } +} + +/* ============================================================ + WS + ============================================================ */ +async function ensureWsSessions() { + const exists = await db.schema.hasTable('ws_sessions'); + if (!exists) { + await db.schema.createTable('ws_sessions', (t) => { + t.string('session_id').notNullable(); + t.string('device_id').notNullable(); + t.string('user').notNullable(); + t.integer('connected_at').notNullable(); + t.integer('last_ack').notNullable(); + t.integer('last_sync').nullable(); + t.boolean('need_full_sync').notNullable().defaultTo(false); + + t.primary(['session_id', 'device_id']); + }); + console.log("✔ ws_sessions creata"); + } else { + const cols = [ + { name: 'device_id', type: 'string' }, + { name: 'last_sync', type: 'integer' } + ]; + + for (const c of cols) { + const existsCol = await db.schema.hasColumn('ws_sessions', c.name); + if (!existsCol) { + await db.schema.alterTable('ws_sessions', (t) => { + if (c.type === 'string') t.string(c.name).notNullable().defaultTo('default'); + if (c.type === 'integer') t.integer(c.name).nullable(); + }); + console.log(`✔ aggiunta colonna ws_sessions.${c.name}`); + } + } + } +} + +async function ensureWsPending() { + const exists = await db.schema.hasTable('ws_pending_events'); + if (!exists) { + await db.schema.createTable('ws_pending_events', (t) => { + t.string('event_id').primary(); + t.string('session_id').notNullable(); + t.text('payload').notNullable(); + t.integer('sent_at').notNullable(); + t.integer('retries').notNullable().defaultTo(0); + }); + console.log("✔ ws_pending_events creata"); + } +} + +async function ensurePendingEvents() { + const exists = await db.schema.hasTable('pending_events'); + if (!exists) { + await db.schema.createTable('pending_events', (t) => { + t.increments('id').primary(); + t.string('user_id').notNullable(); + t.string('session_id').notNullable(); + t.string('device_id').notNullable(); + t.string('event_id').notNullable(); + t.text('payload').notNullable(); + t.integer('created_at').notNullable(); + + t.unique(['user_id', 'session_id', 'device_id', 'event_id']); + }); + console.log("✔ pending_events creata"); + } +} + +/* ============================================================ + 🏃 ATTIVITÀ — nuova tabella + ============================================================ */ +async function ensureActivities() { + const exists = await db.schema.hasTable('activities'); + if (!exists) { + await db.schema.createTable('activities', (t) => { + t.string('id').primary(); + t.string('user'); + t.string('cartella'); + + t.string('file_name'); + t.string('file_extension'); + t.string('format'); + t.string('display_name'); + t.string('route_name'); + t.string('title'); + t.string('description'); + t.string('notes'); + t.string('sport'); + t.string('activity_type'); + t.string('device_manufacturer'); + t.string('device_model'); + t.string('source_app'); + + t.bigInteger('start_time_millis'); + t.bigInteger('end_time_millis'); + t.bigInteger('duration_millis'); + + t.float('distance_meters'); + t.float('elevation_gain_meters'); + t.float('elevation_loss_meters'); + + t.float('min_elevation_meters'); + t.float('max_elevation_meters'); + + t.float('file_distance_meters'); + t.float('file_elevation_gain_meters'); + + t.float('computed_distance_meters'); + t.float('computed_elevation_gain_meters'); + + t.float('average_speed_mps'); + t.float('max_speed_mps'); + t.float('average_pace_sec_per_km'); + + t.integer('average_heart_rate'); + t.integer('max_heart_rate'); + t.integer('average_cadence'); + t.integer('max_cadence'); + + t.integer('calories'); + t.integer('power_avg'); + t.integer('power_max'); + + t.integer('point_count'); + t.integer('segment_count'); + t.integer('lap_count'); + + t.float('min_latitude'); + t.float('max_latitude'); + t.float('min_longitude'); + t.float('max_longitude'); + t.float('center_latitude'); + t.float('center_longitude'); + + t.bigInteger('size_bytes'); + t.bigInteger('last_modified_millis'); + t.bigInteger('date_added_millis'); + t.bigInteger('date_modified_millis'); + t.bigInteger('last_scan_millis'); + + t.boolean('is_favorite'); + t.boolean('is_hidden'); + t.integer('rating'); + + t.text('tags'); + t.text('track_preview'); + t.string('scan_status'); + t.text('scan_error'); + t.string('path'); + t.text('extPath').notNullable().defaultTo(''); + t.integer('hybrid').notNullable().defaultTo(0); + t.text('diagnostics'); + t.text('location'); + t.string('fast_hash'); + t.datetime('deleted_at').nullable(); + t.datetime('created_at').defaultTo(db.fn.now()); + t.datetime('updated_at').defaultTo(db.fn.now()); + }); + console.log("✔ activities creata"); + } +} + +/* ============================================================ + GEOCODING — coda asincrona + ============================================================ */ +async function ensureGeocodeJobs() { + const exists = await db.schema.hasTable('geocode_jobs'); + + if (!exists) { + await db.schema.createTable('geocode_jobs', (t) => { + t.string('id').primary(); + + t.string('entity_type').notNullable(); + t.string('entity_id').notNullable(); + + t.float('lat').notNullable(); + t.float('lon').notNullable(); + + t.string('status') + .notNullable() + .defaultTo('pending'); + + t.integer('attempts') + .notNullable() + .defaultTo(0); + + t.text('last_error'); + + t.datetime('started_at'); + t.datetime('completed_at'); + + t.datetime('created_at') + .defaultTo(db.fn.now()); + + t.datetime('updated_at') + .defaultTo(db.fn.now()); + + t.index( + ['status'], + 'idx_geocode_jobs_status' + ); + + t.index( + ['entity_type', 'entity_id'], + 'idx_geocode_jobs_entity' + ); + + t.unique( + ['entity_type', 'entity_id'], + 'uq_geocode_jobs_entity' + ); + }); + + console.log("✔ geocode_jobs creata"); + } else { + const uniqueExists = await db('sqlite_master') + .where({ + type: 'index', + name: 'uq_geocode_jobs_entity', + }) + .first(); + + if (!uniqueExists) { + await db.schema.alterTable('geocode_jobs', (t) => { + t.unique( + ['entity_type', 'entity_id'], + 'uq_geocode_jobs_entity' + ); + }); + + console.log( + "✔ indice univoco geocode_jobs aggiunto" + ); + } + } +} + +/* ============================================================ + GEOCODING — cache condivisa + ============================================================ */ +async function ensureGeocodeCache() { + const exists = await db.schema.hasTable('geocode_cache'); + + if (!exists) { + await db.schema.createTable('geocode_cache', (t) => { + t.string('cache_key').primary(); + + t.float('lat').notNullable(); + t.float('lon').notNullable(); + + t.float('alt'); + t.text('location'); + + t.integer('hits') + .notNullable() + .defaultTo(0); + + t.datetime('created_at') + .defaultTo(db.fn.now()); + + t.datetime('updated_at') + .defaultTo(db.fn.now()); + }); + + console.log("✔ geocode_cache creata"); + } +} + +/* ============================================================ + ATTIVITÀ — deleted_hard + ============================================================ */ +async function ensureDeletedHardActivities() { + const exists = await db.schema.hasTable('deleted_hard_activities'); + if (!exists) { + await db.schema.createTable('deleted_hard_activities', (t) => { + t.string('id').primary(); + t.string('user'); + t.datetime('deleted_at').defaultTo(db.fn.now()); + }); + console.log("✔ deleted_hard_activities creata"); + } +} + +/* ============================================================ + ATTIVITÀ — sync_done + ============================================================ */ +async function ensureSyncDoneActivities() { + const exists = await db.schema.hasTable('sync_done_activities'); + if (!exists) { + await db.schema.createTable('sync_done_activities', (t) => { + t.string('session_id'); + t.string('device_id'); + t.bigInteger('last_sync'); + }); + console.log("✔ sync_done_activities creata"); + } +} + +/* ============================================================ + VOLTI + ============================================================ */ +async function ensureFaces() { + const exists = await db.schema.hasTable('faces'); + + if (!exists) { + await db.schema.createTable('faces', (t) => { + // ID univoco del volto + t.string('id').primary(); + + // Foto da cui è stato estratto + t.string('photo_id').notNullable(); + t.integer('face_index').notNullable(); + + // Bounding box nel frame originale + t.float('x').notNullable(); + t.float('y').notNullable(); + t.float('width').notNullable(); + t.float('height').notNullable(); + + // Confidence del detector SCRFD + t.float('score').notNullable(); + + // 5 landmark × 2 coordinate + t.float('landmark_0_x'); + t.float('landmark_0_y'); + + t.float('landmark_1_x'); + t.float('landmark_1_y'); + + t.float('landmark_2_x'); + t.float('landmark_2_y'); + + t.float('landmark_3_x'); + t.float('landmark_3_y'); + + t.float('landmark_4_x'); + t.float('landmark_4_y'); + + // 128 × float32 = 512 byte + t.binary('embedding').notNullable(); + + // Risultato HDBSCAN. + // NULL = non ancora clusterizzato. + t.string('cluster_id').nullable(); + + // Persona assegnata dall'utente. + // NULL = identità non ancora assegnata. + t.string('person_id').nullable(); + + // Stato applicativo del volto. + // detected / clustered / assigned + t.string('status').notNullable().defaultTo('detected'); + + t.datetime('created_at').defaultTo(db.fn.now()); + t.datetime('updated_at').defaultTo(db.fn.now()); + + // Indici principali + t.index(['photo_id']); + t.index(['cluster_id']); + t.index(['person_id']); + t.index(['status']); + + t.unique(['photo_id', 'face_index']); + // Evita due record per lo stesso volto nella stessa foto + // solo se il nostro scanner assegnerà un face_index. + }); + + console.log("✔ faces creata"); + } +} + + +/* ============================================================ + CODA SCANSIONE VOLTI + ============================================================ */ +async function ensureFaceScanJobs() { + const exists = + await db.schema.hasTable('face_scan_jobs'); + + if (!exists) { + await db.schema.createTable( + 'face_scan_jobs', + (t) => { + t.string('photo_id').primary(); + + // pending / processing / done / error + t.string('status') + .notNullable() + .defaultTo('pending'); + + t.integer('attempts') + .notNullable() + .defaultTo(0); + + t.integer('rescan_requested') + .notNullable() + .defaultTo(0); + + t.integer('faces_detected').nullable(); + t.integer('execution_ms').nullable(); + t.integer('applied_rotation').nullable(); + + t.text('last_error').nullable(); + + t.datetime('started_at').nullable(); + t.datetime('completed_at').nullable(); + + t.datetime('created_at') + .defaultTo(db.fn.now()); + + t.datetime('updated_at') + .defaultTo(db.fn.now()); + + t.index(['status']); + t.index(['updated_at']); + }, + ); + + console.log("✔ face_scan_jobs creata"); + } else { + const hasRescanRequested = + await db.schema.hasColumn( + 'face_scan_jobs', + 'rescan_requested', + ); + + if (!hasRescanRequested) { + await db.schema.alterTable( + 'face_scan_jobs', + (t) => { + t.integer('rescan_requested') + .notNullable() + .defaultTo(0); + }, + ); + + console.log( + "✔ aggiunta face_scan_jobs.rescan_requested", + ); + } + } +} + + + +/* ============================================================ + CLUSTER HDBSCAN + ============================================================ */ +async function ensureFaceClusters() { + const exists = await db.schema.hasTable('face_clusters'); + + if (!exists) { + await db.schema.createTable('face_clusters', (t) => { + // ID persistente del cluster + t.string('id').primary(); + + // Identifica una specifica esecuzione di HDBSCAN. + // Esempio: "initial-2026-09-23T04:20:00" + t.string('run_id').notNullable(); + + // Label numerica prodotta da HDBSCAN. + // Può essere -1 per noise. + t.integer('label').notNullable(); + + // Numero di facce nel cluster + t.integer('size').notNullable().defaultTo(0); + + // Parametri principali usati per generare il cluster + t.integer('min_samples'); + t.integer('min_cluster_size'); + + // NULL se il cluster non è ancora stato + // associato ad una persona. + t.string('person_id').nullable(); + + t.datetime('created_at').defaultTo(db.fn.now()); + t.datetime('updated_at').defaultTo(db.fn.now()); + + t.index(['run_id']); + t.index(['person_id']); + + // Un label può comparire una sola volta + // all'interno dello stesso run. + t.unique(['run_id', 'label']); + }); + + console.log("✔ face_clusters creata"); + } +} + + +/* ============================================================ + RUN CLUSTERING VOLTI + ============================================================ */ +async function ensureFaceClusterRuns() { + const exists = + await db.schema.hasTable( + 'face_cluster_runs', + ); + + if (!exists) { + await db.schema.createTable( + 'face_cluster_runs', + (t) => { + // Identificativo stabile della run. + t.string('id').primary(); + + // Proprietario della libreria fotografica. + t.string('user') + .notNullable(); + + // pending / processing / done / error + t.string('status') + .notNullable() + .defaultTo('pending'); + + // all / unclustered / unknown + t.string('scope') + .notNullable() + .defaultTo('all'); + + t.integer('embedding_count') + .notNullable() + .defaultTo(0); + + t.integer('min_cluster_size') + .notNullable(); + + t.integer('min_samples') + .notNullable(); + + t.string('metric') + .notNullable() + .defaultTo('euclidean'); + + // eom / leaf + t.string('selection_method') + .notNullable() + .defaultTo('eom'); + + // sklearn / native / opencl-native + t.string('engine') + .notNullable() + .defaultTo('sklearn'); + + t.string('engine_version') + .nullable(); + + t.integer('cluster_count') + .nullable(); + + t.integer('clustered_faces') + .nullable(); + + t.integer('noise_faces') + .nullable(); + + t.float('noise_percent') + .nullable(); + + t.text('last_error') + .nullable(); + + t.datetime('started_at') + .nullable(); + + t.datetime('completed_at') + .nullable(); + + t.datetime('created_at') + .defaultTo(db.fn.now()); + + t.datetime('updated_at') + .defaultTo(db.fn.now()); + + t.index(['user']); + t.index(['status']); + t.index(['user', 'created_at']); + }, + ); + + console.log( + "✔ face_cluster_runs creata", + ); + } +} + + +/* ============================================================ + APPARTENENZE DEI VOLTI AI CLUSTER + ============================================================ */ +async function ensureFaceClusterMemberships() { + const exists = + await db.schema.hasTable( + 'face_cluster_memberships', + ); + + if (!exists) { + await db.schema.createTable( + 'face_cluster_memberships', + (t) => { + // Identificativo stabile della membership. + t.string('id').primary(); + + // Run HDBSCAN che ha prodotto il risultato. + t.string('run_id') + .notNullable(); + + // NULL per i volti classificati come noise. + t.string('cluster_id') + .nullable(); + + t.string('face_id') + .notNullable(); + + // Label originale HDBSCAN. + // -1 indica noise. + t.integer('label') + .notNullable(); + + // Probabilità di appartenenza HDBSCAN. + t.float('probability') + .notNullable() + .defaultTo(0); + + t.integer('noise_flag') + .notNullable() + .defaultTo(0); + + // suggested / accepted / excluded + t.string('review_status') + .notNullable() + .defaultTo('suggested'); + + t.string('reviewed_by') + .nullable(); + + t.datetime('reviewed_at') + .nullable(); + + t.datetime('created_at') + .defaultTo(db.fn.now()); + + t.datetime('updated_at') + .defaultTo(db.fn.now()); + + // Un volto compare una sola volta per run. + t.unique([ + 'run_id', + 'face_id', + ]); + + t.index(['run_id']); + t.index(['cluster_id']); + t.index(['face_id']); + t.index(['review_status']); + t.index([ + 'run_id', + 'label', + ]); + }, + ); + + console.log( + "✔ face_cluster_memberships creata", + ); + } +} + + +/* ============================================================ + ESCLUSIONI MANUALI PERSONA-VOLTO + ============================================================ */ +async function ensurePersonFaceExclusions() { + const exists = + await db.schema.hasTable( + 'person_face_exclusions', + ); + + if (!exists) { + await db.schema.createTable( + 'person_face_exclusions', + (t) => { + // Persona dalla quale il volto è stato escluso. + t.string('person_id') + .notNullable(); + + t.string('face_id') + .notNullable(); + + // Proprietario della libreria. + t.string('user') + .notNullable(); + + t.text('reason') + .nullable(); + + t.string('created_by') + .nullable(); + + t.datetime('created_at') + .defaultTo(db.fn.now()); + + t.primary([ + 'person_id', + 'face_id', + ]); + + t.index(['user']); + t.index(['face_id']); + }, + ); + + console.log( + "✔ person_face_exclusions creata", + ); + } +} + + +/* ============================================================ + PERSONE + ============================================================ */ +async function ensurePersons() { + const exists = await db.schema.hasTable('persons'); + + if (!exists) { + await db.schema.createTable('persons', (t) => { + // ID stabile dell'identità applicativa + t.string('id').primary(); + + // Utente proprietario della libreria + t.string('user').nullable(); + + // Nome assegnato dall'utente + t.string('name').notNullable(); + + t.datetime('created_at').defaultTo(db.fn.now()); + t.datetime('updated_at').defaultTo(db.fn.now()); + + t.index(['user']); + t.index(['name']); + }); + + console.log("✔ persons creata"); + } +} + +/* ============================================================ + PROTOTYPE ATTIVI DELLE PERSONE + ============================================================ */ +async function ensurePersonFacePrototypes() { + const exists = + await db.schema.hasTable( + 'person_face_prototypes', + ); + + if (!exists) { + await db.schema.createTable( + 'person_face_prototypes', + (t) => { + // Persona rappresentata dal prototype. + t.string('person_id') + .notNullable(); + + // Volto reale usato come riferimento. + t.string('face_id') + .notNullable(); + + // Proprietario della libreria. + t.string('user') + .notNullable(); + + // cluster / noise / manual + t.string('source_type') + .notNullable(); + + // cluster_medoid / + // greedy_leave_one_out_gain / + // manual + t.string('selection_method') + .notNullable(); + + // Ordine nel set dei prototype. + t.integer('selection_order') + .nullable(); + + t.float('detector_score') + .nullable(); + + t.float('quality_score') + .nullable(); + + // 1 = usato nell'indice corrente. + t.integer('active') + .notNullable() + .defaultTo(1); + + // 1 = prototype protetto manualmente. + // Il ricalcolo automatico non lo disattiva. + t.integer('locked') + .notNullable() + .defaultTo(0); + + // Nome o versione dell'esperimento. + t.string('experiment_name') + .nullable(); + + // Metriche e metadati in formato JSON. + t.text('metrics_json') + .nullable(); + + t.string('created_by') + .nullable(); + + t.datetime('created_at') + .defaultTo(db.fn.now()); + + t.datetime('updated_at') + .defaultTo(db.fn.now()); + + t.primary([ + 'person_id', + 'face_id', + ]); + + t.index(['user']); + t.index(['person_id']); + t.index(['face_id']); + t.index(['active']); + t.index([ + 'user', + 'active', + ]); + }, + ); + + console.log( + "✔ person_face_prototypes creata", + ); + } else { + const hasLocked = + await db.schema.hasColumn( + 'person_face_prototypes', + 'locked', + ); + + if (!hasLocked) { + await db.schema.alterTable( + 'person_face_prototypes', + (t) => { + t.integer('locked') + .notNullable() + .defaultTo(0); + }, + ); + + console.log( + "✔ aggiunta person_face_prototypes.locked", + ); + } + } +} + + +/* ============================================================ + VOLTI CONFERMATI NON ADATTI COME RIFERIMENTO + ============================================================ */ +async function ensurePersonFaceReferenceExclusions() { + const exists = + await db.schema.hasTable( + 'person_face_reference_exclusions', + ); + + if (!exists) { + await db.schema.createTable( + 'person_face_reference_exclusions', + (t) => { + // La persona rimane confermata. + t.string('person_id') + .notNullable(); + + // Il volto non deve entrare + // nell'indice di riconoscimento. + t.string('face_id') + .notNullable(); + + // Proprietario della libreria. + t.string('user') + .notNullable(); + + // Esempi: + // occlusion_extreme_pose + // blur + // face_too_small + // bad_crop + // manual + t.string('reason_code') + .notNullable(); + + t.text('note') + .nullable(); + + t.string('created_by') + .nullable(); + + t.datetime('created_at') + .defaultTo(db.fn.now()); + + t.datetime('updated_at') + .defaultTo(db.fn.now()); + + t.primary([ + 'person_id', + 'face_id', + ]); + + t.index(['user']); + t.index(['person_id']); + t.index(['face_id']); + t.index(['reason_code']); + }, + ); + + console.log( + "✔ person_face_reference_exclusions creata", + ); + } +} + + +/* ============================================================ + STATO DELL'INDICE DI RICONOSCIMENTO + ============================================================ */ +async function ensureFaceRecognitionIndexState() { + const exists = + await db.schema.hasTable( + 'face_recognition_index_state', + ); + + if (!exists) { + await db.schema.createTable( + 'face_recognition_index_state', + (t) => { + // Una riga per proprietario + // della libreria. + t.string('user') + .primary(); + + // missing / dirty / building / + // ready / error + t.string('status') + .notNullable() + .defaultTo('missing'); + + // 1 se l'indice deve essere + // ricostruito. + t.integer('dirty') + .notNullable() + .defaultTo(1); + + t.integer('version') + .notNullable() + .defaultTo(0); + + t.integer('prototype_count') + .notNullable() + .defaultTo(0); + + t.string('index_path') + .nullable(); + + t.string('metadata_path') + .nullable(); + + t.datetime('built_at') + .nullable(); + + t.text('last_error') + .nullable(); + + t.datetime('created_at') + .defaultTo(db.fn.now()); + + t.datetime('updated_at') + .defaultTo(db.fn.now()); + + t.index(['status']); + t.index(['dirty']); + }, + ); + + console.log( + "✔ face_recognition_index_state creata", + ); + } +} + + +/* ============================================================ + JOB DI RICALCOLO DEI PROTOTYPE + ============================================================ */ +async function ensureFacePrototypeJobs() { + const exists = + await db.schema.hasTable( + 'face_prototype_jobs', + ); + + if (!exists) { + await db.schema.createTable( + 'face_prototype_jobs', + (t) => { + t.string('id') + .primary(); + + t.string('user') + .notNullable(); + + t.string('person_id') + .notNullable(); + + // pending / processing / done / + // error / superseded + t.string('status') + .notNullable() + .defaultTo('pending'); + + // Motivo dell'ultimo aggiornamento. + t.string('reason') + .notNullable(); + + t.string('requested_by') + .nullable(); + + t.datetime('requested_at') + .notNullable() + .defaultTo(db.fn.now()); + + t.datetime('started_at') + .nullable(); + + t.datetime('completed_at') + .nullable(); + + t.integer('attempts') + .notNullable() + .defaultTo(0); + + t.text('last_error') + .nullable(); + + t.text('result_json') + .nullable(); + + // Firma dello stato dei volti + // all'inizio dell'elaborazione. + t.string('input_signature') + .nullable(); + + t.datetime('created_at') + .defaultTo(db.fn.now()); + + t.datetime('updated_at') + .defaultTo(db.fn.now()); + + t.index(['user']); + t.index(['person_id']); + t.index(['status']); + t.index([ + 'user', + 'status', + 'requested_at', + ]); + + }, + ); + + console.log( + "✔ face_prototype_jobs creata", + ); + } +} + + +/* ============================================================ + VALUTAZIONI DI COPERTURA DEI PROTOTYPE + ============================================================ */ +async function ensurePersonFaceRecognitionEvaluations() { + const exists = + await db.schema.hasTable( + 'person_face_recognition_evaluations', + ); + + if (!exists) { + await db.schema.createTable( + 'person_face_recognition_evaluations', + (t) => { + // Persona rispetto alla quale + // viene valutato il volto. + t.string('person_id') + .notNullable(); + + t.string('face_id') + .notNullable(); + + t.string('user') + .notNullable(); + + // Job prototype che ha prodotto + // questa valutazione. + t.string('prototype_job_id') + .notNullable(); + + t.integer('prototype_count') + .notNullable(); + + // Migliore similarità verso + // i prototype attivi, in modalità + // leave-one-out. + t.float('best_similarity') + .nullable(); + + t.float('top3_mean') + .nullable(); + + t.string('best_prototype_face_id') + .nullable(); + + // Soglia visualizzata in galleria. + t.float('recognition_threshold') + .notNullable() + .defaultTo(0.50); + + t.integer('recognized') + .notNullable() + .defaultTo(0); + + t.datetime('evaluated_at') + .notNullable() + .defaultTo(db.fn.now()); + + t.datetime('created_at') + .defaultTo(db.fn.now()); + + t.datetime('updated_at') + .defaultTo(db.fn.now()); + + t.primary([ + 'person_id', + 'face_id', + ]); + + t.index(['user']); + t.index(['person_id']); + t.index(['face_id']); + t.index(['prototype_job_id']); + t.index(['recognized']); + t.index([ + 'person_id', + 'recognized', + ]); + }, + ); + + console.log( + "✔ person_face_recognition_evaluations creata", + ); + } +} + + +/* ============================================================ + INIT + ============================================================ */ +/* async function init() { + try { + console.log("🔧 init DB..."); + await ensurePhotos(); + await ensureDeletedHard(); + + await ensureActivities(); + await ensureDeletedHardActivities(); + await ensureSyncDoneActivities(); + + await ensureWsSessions(); + await ensureWsPending(); + await ensurePendingEvents(); + await ensureServerEventOutbox(db); + + console.log("✅ init completato"); + process.exit(0); + } catch (err) { + console.error("❌ errore init:", err); + process.exit(1); + } +} +*/ +async function init() { + try { + console.log("🔧 init DB..."); + + await ensurePhotos(); + await ensureDeletedHard(); + + await ensureActivities(); + await ensureGeocodeJobs(); + await ensureGeocodeCache(); + await ensureDeletedHardActivities(); + await ensureSyncDoneActivities(); + + await ensureWsSessions(); + await ensureWsPending(); + await ensurePendingEvents(); + + // Face recognition + await ensureFaces(); + await ensureFaceScanJobs(); + await ensureFaceScanAutomation(db); + await ensureFaceClusters(); + await ensureFaceClusterRuns(); + await ensureFaceClusterMemberships(); + + await ensurePersons(); + await ensurePersonFaceExclusions(); + + await ensurePersonFacePrototypes(); + await ensurePersonFaceReferenceExclusions(); + await ensureFaceRecognitionIndexState(); + await ensureFacePrototypeJobs(); + await ensurePersonFaceRecognitionEvaluations(); + + console.log("✅ init completato"); + process.exit(0); + } catch (err) { + console.error("❌ errore init:", err); + process.exit(1); + } +} + + +init(); diff --git a/db/knex.js b/db/knex.js new file mode 100644 index 0000000..9582978 --- /dev/null +++ b/db/knex.js @@ -0,0 +1,36 @@ +// db/knex.js +const knex = require('knex'); +const path = require('path'); + +const db = knex({ + client: 'better-sqlite3', + + connection: { + filename: './api_v1/database.sqlite', + }, + + useNullAsDefault: true, + + pool: { + afterCreate(conn, done) { + try { + conn.pragma('journal_mode = WAL'); + conn.pragma('busy_timeout = 30000'); + + console.log('[DB] journal_mode=WAL'); + console.log('[DB] busy_timeout=30000'); + } catch (err) { + console.error('[DB] pragma error', err); + } + + done(null, conn); + }, + }, +}); + +console.log( + '📌 DB PATH:', + path.resolve('./api_v1/database.sqlite') +); + +module.exports = db; diff --git a/db/photos.js b/db/photos.js new file mode 100644 index 0000000..ca7d9fe --- /dev/null +++ b/db/photos.js @@ -0,0 +1,73 @@ +// db/photos.js +const knex = require('./knex'); + +async function insertOrUpdatePhoto(row) { + // row deve contenere almeno id, user, cartella, name, path, thumb1, thumb2, fast_hash, mtimeMs, etc. + const exists = await knex('photos').where({ id: row.id }).first(); + if (!exists) { + await knex('photos').insert({ + id: row.id, + user: row.user, + cartella: row.cartella, + name: row.name, + path: row.path, + thumb1: row.thumb1 || null, + thumb2: row.thumb2 || null, + mime_type: row.mime_type || null, + width: row.width || null, + height: row.height || null, + rotation: row.rotation || null, + size_bytes: row.size_bytes || null, + mtimeMs: row.mtimeMs || null, + taken_at: row.taken_at || null, + fast_hash: row.fast_hash || null, + created_at: knex.fn.now(), + updated_at: knex.fn.now() + }); + } else { + await knex('photos').where({ id: row.id }).update({ + path: row.path, + thumb1: row.thumb1 || exists.thumb1, + thumb2: row.thumb2 || exists.thumb2, + mime_type: row.mime_type || exists.mime_type, + width: row.width || exists.width, + height: row.height || exists.height, + rotation: row.rotation || exists.rotation, + size_bytes: row.size_bytes || exists.size_bytes, + mtimeMs: row.mtimeMs || exists.mtimeMs, + taken_at: row.taken_at || exists.taken_at, + fast_hash: row.fast_hash || exists.fast_hash, + updated_at: knex.fn.now(), + deleted_at: null + }); + } +} + +async function markPhotoDeletedHard(id, user) { + await knex('photos').where({ id, user }).del(); + await knex('deleted_hard').insert({ id, user, deleted_at: knex.fn.now() }).catch(() => {}); +} + +async function getPhotosSince(user, sinceIso, limit = 1000) { + // ritorna foto aggiornate dopo sinceIso (ISO string) + return knex('photos') + .where({ user }) + .andWhere('updated_at', '>', sinceIso) + .orderBy('updated_at', 'asc') + .limit(limit) + .select('*'); +} + +async function getDeletedHardSince(user, sinceIso) { + return knex('deleted_hard') + .where({ user }) + .andWhere('deleted_at', '>', sinceIso) + .select('id', 'deleted_at'); +} + +module.exports = { + insertOrUpdatePhoto, + markPhotoDeletedHard, + getPhotosSince, + getDeletedHardSince +}; diff --git a/db/serverEventOutbox.js b/db/serverEventOutbox.js new file mode 100644 index 0000000..0e676ab --- /dev/null +++ b/db/serverEventOutbox.js @@ -0,0 +1,147 @@ +'use strict'; + +/* + * Outbox inter-processo. + * + * I daemon possono produrre eventi senza importare ws-server. + * Solo il processo server consuma questi eventi e li inoltra + * al WebSocket reale. + */ + +let ensurePromise = null; + +async function ensureServerEventOutbox(db) { + if (!ensurePromise) { + ensurePromise = (async () => { + const exists = await db.schema.hasTable('server_event_outbox'); + + if (!exists) { + await db.schema.createTable('server_event_outbox', (t) => { + t.increments('id').primary(); + t.string('channel').notNullable(); + t.string('user').notNullable(); + t.text('payload').notNullable(); + + t.string('status') + .notNullable() + .defaultTo('pending'); + + t.integer('attempts') + .notNullable() + .defaultTo(0); + + t.text('last_error').nullable(); + + t.datetime('created_at') + .notNullable() + .defaultTo(db.fn.now()); + + t.datetime('updated_at') + .notNullable() + .defaultTo(db.fn.now()); + + t.datetime('processed_at').nullable(); + + t.index( + ['status', 'id'], + 'server_event_outbox_status_id_idx' + ); + }); + + console.log( + '[ServerEventOutbox] tabella server_event_outbox creata' + ); + } + })().catch((err) => { + ensurePromise = null; + throw err; + }); + } + + return ensurePromise; +} + +async function enqueueServerEvent( + db, + { + channel, + user, + payload, + } +) { + if (!channel || !user || !payload) { + throw new Error( + 'enqueueServerEvent richiede channel, user e payload' + ); + } + + await ensureServerEventOutbox(db); + + const now = new Date().toISOString(); + + await db('server_event_outbox').insert({ + channel, + user, + payload: JSON.stringify(payload), + status: 'pending', + attempts: 0, + last_error: null, + created_at: now, + updated_at: now, + processed_at: null, + }); +} + +/* + * C'e' un solo consumer nel processo server. + * Non serve tenere il job in processing durante il broadcast: + * viene selezionato pending e marcato done soltanto dopo + * un broadcast completato senza eccezioni. + */ +async function getNextPendingServerEvent(db) { + await ensureServerEventOutbox(db); + + return db('server_event_outbox') + .where({ status: 'pending' }) + .orderBy('id', 'asc') + .first(); +} + +async function completeServerEvent(db, id) { + const now = new Date().toISOString(); + + return db('server_event_outbox') + .where({ + id, + status: 'pending', + }) + .update({ + status: 'done', + processed_at: now, + updated_at: now, + last_error: null, + }); +} + +async function failServerEvent(db, id, error) { + const now = new Date().toISOString(); + + return db('server_event_outbox') + .where({ + id, + status: 'pending', + }) + .update({ + attempts: db.raw('attempts + 1'), + last_error: String(error || 'Errore sconosciuto'), + updated_at: now, + }); +} + +module.exports = { + ensureServerEventOutbox, + enqueueServerEvent, + getNextPendingServerEvent, + completeServerEvent, + failServerEvent, +}; diff --git a/db/sqliteRetry.js b/db/sqliteRetry.js new file mode 100644 index 0000000..bf192ac --- /dev/null +++ b/db/sqliteRetry.js @@ -0,0 +1,68 @@ +'use strict'; + +const SQLITE_BUSY_RETRY_DELAYS_MS = [ + 25, + 50, + 100, + 200, + 400, + 800, + 1600, +]; + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function isSqliteBusy(error) { + if (!error) return false; + + if ( + error.code === 'SQLITE_BUSY' || + error.code === 'SQLITE_LOCKED' + ) { + return true; + } + + const message = String( + error.message || error + ).toLowerCase(); + + return ( + message.includes('database is locked') || + message.includes('database table is locked') + ); +} + +async function withSqliteBusyRetry(label, operation) { + let retry = 0; + + while (true) { + try { + return await operation(); + } catch (error) { + if ( + !isSqliteBusy(error) || + retry >= SQLITE_BUSY_RETRY_DELAYS_MS.length + ) { + throw error; + } + + const delay = SQLITE_BUSY_RETRY_DELAYS_MS[retry]; + retry += 1; + + console.warn( + `[SQLiteRetry] SQLITE_BUSY ${label}; ` + + `retry=${retry}/${SQLITE_BUSY_RETRY_DELAYS_MS.length} ` + + `delay=${delay}ms` + ); + + await sleep(delay); + } + } +} + +module.exports = { + isSqliteBusy, + withSqliteBusyRetry, +}; diff --git a/diff_js_css_html.py b/diff_js_css_html.py new file mode 100644 index 0000000..80db71e --- /dev/null +++ b/diff_js_css_html.py @@ -0,0 +1,86 @@ + +#!/usr/bin/env python3 + +from pathlib import Path +import hashlib +import argparse +import sys + +parser = argparse.ArgumentParser( + description="Confronta i file JS, CSS e HTML della cartella corrente " + "con un'altra cartella specificata come parametro." +) +parser.add_argument( + "altra_cartella", + help="Percorso della cartella da confrontare con quella corrente" +) +args = parser.parse_args() + +old = Path(".").resolve() +new = Path(args.altra_cartella).expanduser().resolve() + +if not old.is_dir(): + sys.exit(f"ERRORE: cartella corrente non valida: {old}") + +if not new.is_dir(): + sys.exit(f"ERRORE: cartella inesistente: {new}") + +exts = {".js", ".mjs", ".cjs", ".jsx", ".css", ".html", ".htm"} +excluded = { + "node_modules", ".git", ".venv", ".venv-clustering", + "var", "native", "coverage", "dist", "build" +} + + +def collect(root): + result = {} + + for p in root.rglob("*"): + if not p.is_file() or p.suffix.lower() not in exts: + continue + + rel = p.relative_to(root) + + if any(part in excluded or part.startswith("backup-") + for part in rel.parts): + continue + + result[str(rel)] = p + + return result + + +a = collect(old) +b = collect(new) + +added = sorted(b.keys() - a.keys()) +removed = sorted(a.keys() - b.keys()) +common = sorted(a.keys() & b.keys()) + +modified = [ + f for f in common + if hashlib.sha256(a[f].read_bytes()).digest() + != hashlib.sha256(b[f].read_bytes()).digest() +] + +print(f"\nCARTELLA CORRENTE: {old}") +print(f"ALTRA CARTELLA: {new}") + +print(f"\nAGGIUNTI nella cartella corrente: {len(removed)}") +for f in removed: + print(" +", f) + +print(f"\nASSENTI dalla cartella corrente: {len(added)}") +for f in added: + print(" -", f) + +print(f"\nMODIFICATI: {len(modified)}") +for f in modified: + print(" *", f) + +Path("/tmp/s184js-modificati.txt").write_text( + "\n".join(modified) + ("\n" if modified else ""), + encoding="utf-8" +) + +print("\nElenco dei modificati salvato in /tmp/s184js-modificati.txt") diff --git a/docs/README_WS.md b/docs/README_WS.md new file mode 100644 index 0000000..24eab71 --- /dev/null +++ b/docs/README_WS.md @@ -0,0 +1,278 @@ +Perfetto Fabio — ora ti preparo il diagramma del flusso completo, chiaro, leggibile e fedele al comportamento reale del tuo sistema. + +È un diagramma architetturale, non un semplice schema: mostra tutto il ciclo di vita di una sessione WebSocket affidabile, con SQLite, retry, timeout, needfullsync e sync del client. + +--- + +🟦 DIAGRAMMA DEL FLUSSO COMPLETO — WebSocket Affidabile + Sync + +Lo divido in 6 sezioni: + +1. Connessione iniziale +2. Autenticazione + session_id +3. Invio evento dal server +4. Ricezione evento dal client +5. Retry + Timeout + needfullsync +6. Riconnessione del client + +--- + +🟩 1) CONNESSIONE INIZIALE + +` +CLIENT (browser) SERVER (ws-server.js) +────────────────────────────────────────────────────────────────────── + +startWebSocket() + ↓ +Apre WebSocket ------------------------→ ws.on("connection") + crea ws object + ws.authenticated = false +` + +--- + +🟩 2) AUTENTICAZIONE + SESSION_ID + +` +CLIENT SERVER +────────────────────────────────────────────────────────────────────── + +Invia: +{ + type: "auth", + token: JWT, + session_id: +} + ↓ ws.on("message") + verifica JWT + cerca sessione in SQLite + se non esiste → createSession() + aggiorna last_ack + salva wsBySession[session_id] = ws + +Riceve: +{ + type: "auth_ok", + user: "...", + session_id: "...", + needfullsync: true/false +} + ↓ +Se needfullsync = true → incrementalSync() +` + +--- + +🟩 3) INVIO EVENTO DAL SERVER (broadcast affidabile) + +` +SERVER +────────────────────────────────────────────────────────────────────── + +broadcastToUserReliable(user, payload) + ↓ +Genera event_id (UUID) + ↓ +Per ogni sessione dell’utente: + - Se ws attivo: + ws.send(payload + event_id) + addPendingEvent(eventid, sessionid) + - Se ws NON attivo: + setSessionNeedFullSync(session_id, true) +` + +--- + +🟩 4) RICEZIONE EVENTO DAL CLIENT + +` +CLIENT +────────────────────────────────────────────────────────────────────── + +Riceve evento: +{ + type: "added" | "removed" | ..., + id: "...", + event_id: "..." +} + + ↓ +Se event_id già in processedEvents: + - invia solo ACK + - ignora evento + - FINE + +Se nuovo: + - applica evento (add/remove foto) + - aggiorna UI + - aggiunge event_id a processedEvents + - invia ACK: + { type: "ack", event_id } +` + +--- + +🟩 5) RETRY + TIMEOUT + NEEDFULLSYNC + +` +SERVER (ogni 3 secondi) +────────────────────────────────────────────────────────────────────── + +Legge da SQLite tutti gli eventi pendenti +con sent_at < now - 3000 ms + +Per ogni evento: + + Se retries < 40 (2 minuti): + - se ws attivo: + ws.send(payload) + updatePendingEventRetry() + - se ws NON attivo: + setSessionNeedFullSync(session_id, true) + deletePendingEvent(event_id) + + Se retries >= 40: + - ws.terminate() + - wsBySession.delete(session_id) + - deleteSession(session_id) ← cancella queue + - (sessione verrà ricreata al prossimo auth) +` + +--- + +🟩 6) RICONNESSIONE DEL CLIENT + +` +CLIENT riapre la tab / si risveglia +────────────────────────────────────────────────────────────────────── + +startWebSocket() + ↓ +Invia auth con lo stesso session_id + ↓ +SERVER: + - trova sessione in SQLite? + NO → la ricrea con needfullsync = true + SÌ → legge needfullsync + + - invia authok con needfull_sync + +CLIENT: + Se needfullsync = true: + incrementalSync() + Altrimenti: + continua con WS realtime +` + +--- + +🟦 RISULTATO DEL FLUSSO + +✔ Nessun evento perso + +✔ Nessun evento duplicato + +✔ Chrome Android non può più “morire in silenzio” + +✔ Multi‑dispositivo perfetto + +✔ Queue pulita dopo 2 minuti + +✔ Sync completo automatico quando serve + +✔ WS leggero (ping solo on‑demand) + +✔ Stato persistente in SQLite + +--- + + +┌──────────────────────────┐ ┌──────────────────────────┐ +│ CLIENT │ │ SERVER │ +└─────────────┬────────────┘ └─────────────┬────────────┘ + │ │ + │ 1) Connessione WS │ + ├──────────────────────────────────────→│ + │ │ + │ 2) AUTH + session_id │ + │ {type:auth, token, session_id} │ + ├──────────────────────────────────────→│ + │ │ + │ │ verifica JWT + │ │ cerca sessione in DB + │ │ crea/aggiorna sessione + │ │ + │ 3) AUTH_OK │ + │ ←──────────────────────────────────────┤ + │ {need_full_sync:true/false} │ + │ │ + │ se need_full_sync → incrementalSync() │ + │ │ +──────────────┼────────────────────────────────────────┼────────────── + │ │ + │ 4) Ricezione evento WS │ + │ {type:added/removed, event_id} │ + │ ←──────────────────────────────────────┤ + │ │ + │ se event_id già visto → ACK │ + │ se nuovo → applica evento │ + │ │ + │ ACK │ + ├──────────────────────────────────────→│ + │ │ +──────────────┼────────────────────────────────────────┼────────────── + │ │ + │ │ 5) Retry loop (ogni 3s) + │ │ cerca eventi pendenti + │ │ + │ │ se retries < 40: + │ │ reinvia evento + │ │ + │ │ se retries >= 40: + │ │ ws.terminate() + │ │ deleteSession() + │ │ (queue eliminata) + │ │ +──────────────┼────────────────────────────────────────┼────────────── + │ │ + │ 6) Riconnessione client │ + ├──────────────────────────────────────→│ + │ {auth, token, session_id} │ + │ │ + │ │ server vede sessione mancante + │ │ → need_full_sync = true + │ │ + │ AUTH_OK │ + │ ←──────────────────────────────────────┤ + │ {need_full_sync:true} │ + │ │ + │ incrementalSync() │ + │ │ +──────────────┴────────────────────────────────────────┴────────────── + +CLIENT → WS CONNECT +CLIENT → AUTH(token, session_id) +SERVER → AUTH_OK(need_full_sync) + +EVENTO: +SERVER → EVENT(event_id) +CLIENT: + se nuovo → applica + ACK + se già visto → solo ACK + +RETRY LOOP: + se retries < 40 → reinvia + se retries >= 40 → chiudi WS + cancella sessione + +RICONNESSIONE: +CLIENT → AUTH(session_id) +SERVER → AUTH_OK(need_full_sync=true) +CLIENT → incrementalSync() + +______ + + +AUTH → EVENTI → ACK → (retry finché vivo) +se client sparisce → timeout → cancella sessione +se torna → need_full_sync → incrementalSync diff --git a/docs/RICONOSCIMENTO_SERVER.md b/docs/RICONOSCIMENTO_SERVER.md new file mode 100644 index 0000000..0bc0f53 --- /dev/null +++ b/docs/RICONOSCIMENTO_SERVER.md @@ -0,0 +1,761 @@ +# Riconoscimento facciale nel server s141js + +## Scopo del documento + +Questo documento descrive il funzionamento attuale del riconoscimento facciale nel server `s141js`. + +La soluzione usa due percorsi paralleli: + +- Python produce il risultato ufficiale e lo salva nel database; +- OpenCL sulla GPU Mali-G610 ricalcola lo stesso risultato in modalità shadow e verifica la coerenza. + +La modalità shadow non sostituisce ancora il risultato Python e non modifica le valutazioni ufficiali. + +## Legenda dei segnaposto + +Il file usa segnaposto al posto dei caratteri Markdown che possono essere alterati durante la copia in chat o nel terminale. + +```text +# titolo livello 1, da convertire in # +## titolo livello 2, da convertire in ## +### titolo livello 3, da convertire in ### +#### titolo livello 4, da convertire in #### +__FENCE__ apertura o chiusura di un blocco di codice +__FENCE_TEXT__ apertura di un blocco text +__FENCE_BASH__ apertura di un blocco bash +__FENCE_JSON__ apertura di un blocco json +__FENCE_JS__ apertura di un blocco javascript +` apice rovescio singolo +``` + +## Architettura generale + +```text +Fotografia + | + v +SCRFD tramite RKNN e NPU + | + v +Bounding box e cinque landmark + | + v +Allineamento del volto a 112 x 112 + | + v +SFace tramite RKNN e NPU + | + v +Embedding float32[128] normalizzato L2 + | + +-------------------------------+ + | | + v v +Pipeline Python ufficiale kNN OpenCL shadow + | | + v v +Valutazioni salvate Confronto di coerenza +nel database Python contro OpenCL + | | + +---------------+---------------+ + | + v + Report e stato del job +``` + +## Componenti principali + +### Face engine RKNN + +Il face engine usa la NPU del dispositivo RK3588 per: + +- rilevare i volti con SCRFD; +- calcolare confidence, bounding box e landmark; +- allineare il volto; +- generare un embedding SFace; +- normalizzare l'embedding con norma L2. + +File principali: + +```text +include/face_config.h +include/face_engine.h +src/face_engine.cc +src/face_recognition.cc +src/face_scan.cc +``` + +Ogni embedding ha il seguente formato: + +```text +Dimensione: 128 componenti +Tipo: float32 +Dimensione binaria: 512 byte +Ordine byte: little-endian +Normalizzazione: L2 +``` + +Poiché gli embedding sono normalizzati, la cosine similarity coincide con il prodotto scalare. + +### Motore kNN nativo + +Il motore nativo è installato in: + +```text +native/face-knn/ +``` + +File principali: + +```text +native/face-knn/bin/face_knn +native/face-knn/kernels/knn_float4_topk.cl +native/face-knn/run-face-knn.sh +native/face-knn/config.json +native/face-knn/SHA256SUMS +``` + +Il launcher `run-face-knn.sh` imposta la directory di lavoro corretta prima di avviare il binario. Questo permette a `face_knn` di trovare il kernel tramite il percorso relativo: + +```text +kernels/knn_float4_topk.cl +``` + +Il motore supporta due backend: + +```text +cpu +opencl +``` + +Il backend CPU è il riferimento numerico e il fallback. Il backend OpenCL usa la GPU Mali-G610. + +### Runtime Node.js + +Il modulo: + +```text +tools/faceKnnRuntime.js +``` + +ha queste responsabilità: + +1. legge la configurazione da `native/face-knn/config.json`; +2. recupera gli embedding dal database SQLite; +3. preserva l'ordine delle query prodotto dall'esperimento Python; +4. rilegge i prototype attivi ordinati per `selection_order`; +5. verifica che ogni embedding sia un Buffer da 512 byte; +6. crea file binari temporanei per query e prototype; +7. genera la self-map per il leave-one-out; +8. avvia il binario nativo; +9. analizza il JSON prodotto; +10. elimina i file biometrici temporanei. + +I file temporanei vengono creati sotto: + +```text +var/face-knn/run-* +``` + +Durante l'esecuzione normale vengono rimossi automaticamente. + +### Shadow runner + +Il comando indipendente: + +```text +tools/runFaceKnnShadow.js +``` + +confronta il risultato Python con quello del backend nativo. + +Confronta per ogni query: + +- prototype migliore; +- best similarity; +- media dei primi tre score; +- decisione recognized; +- esclusione leave-one-out. + +Il report contiene solo metadati e risultati numerici. Non contiene gli embedding. + +## Database e tabelle utilizzate + +Il database attivo è: + +```text +api_v1/database.sqlite +``` + +Le tabelle principali sono: + +### faces + +Contiene i volti e gli embedding da 512 byte. + +### person_face_prototypes + +Contiene i prototype attivi di ogni persona. + +L'ordine è determinato da: + +```text +selection_order +``` + +La corrispondenza con gli indici OpenCL è: + +```text +selection_order 1 -> indice OpenCL 0 +selection_order 2 -> indice OpenCL 1 +selection_order 3 -> indice OpenCL 2 +selection_order 4 -> indice OpenCL 3 +selection_order 5 -> indice OpenCL 4 +``` + +### face_prototype_jobs + +Contiene i job di ricalcolo dei prototype. + +Stati principali: + +```text +pending +processing +done +error +superseded +``` + +### person_face_recognition_evaluations + +Contiene le valutazioni ufficiali prodotte dalla pipeline Python. + +Campi principali: + +```text +face_id +prototype_job_id +prototype_count +best_similarity +top3_mean +best_prototype_face_id +recognition_threshold +recognized +evaluated_at +``` + +### face_recognition_index_state + +Tiene traccia dello stato dell'indice persistente. + +Stato attuale validato: + +```text +user: Fabio +status: dirty +dirty: 1 +version: 0 +prototype_count: 5 +index_path: null +metadata_path: null +built_at: null +last_error: null +``` + +Lo stato `dirty` indica che i prototype sono stati ricalcolati, ma l'indice persistente non è ancora stato costruito. + +## Come nasce un job di ricalcolo + +Il router è montato con il prefisso: + +```text +/face-review +``` + +L'endpoint manuale è: + +```text +POST /face-review/persons/:personId/prototypes/recalculate +``` + +La richiesta richiede: + +```text +Authorization: Bearer TOKEN +Content-Type: application/json +``` + +Esempio di body: + +```json +{ + "reason": "opencl_shadow_validation" +} +``` + +La funzione `queuePrototypeRefresh()`: + +- crea un job pending se non ne esiste uno; +- aggiorna il job pending esistente per evitare duplicati; +- salva reason, requested_by e timestamp. + +Il ricalcolo viene accodato anche automaticamente quando: + +- viene assegnato un cluster a una persona; +- un volto viene rimosso da una persona; +- un volto noise viene assegnato; +- un volto viene riassegnato; +- più volti noise vengono assegnati insieme. + +## Elaborazione del job + +Il worker è: + +```text +tools/runPrototypeWorker.js +``` + +Esecuzione manuale: + +```bash +node tools/runPrototypeWorker.js \ + --persist \ + --job-id JOB_ID +``` + +Il flusso è il seguente. + +### 1. Acquisizione del job + +Il worker cerca un job con stato `pending`, lo porta a `processing` e incrementa `attempts`. + +### 2. Firma degli input + +Il worker calcola una firma SHA-256 basata sui volti e sulle esclusioni di riferimento. + +Se la firma cambia durante il lavoro, il job viene marcato `superseded` e viene creato un job sostitutivo. + +### 3. Esperimento Python + +Il worker avvia: + +```text +tools/prototype_experiment.py +``` + +Parametri attuali: + +```text +noise_prototypes: 2 +greedy_additions: 2 +min_detector_score: 0.60 +``` + +L'esperimento produce: + +```text +prototype_sets.greedy_gain +evaluations.greedy_gain.queries +evaluations.greedy_gain.summary +``` + +Ogni query Python contiene: + +```text +face_id +photo_name +is_prototype +best_similarity +best_prototype_face_id +top3_mean +``` + +### 4. Selezione dei prototype + +Il worker: + +1. applica le esclusioni manuali; +2. mantiene i prototype bloccati; +3. disattiva i prototype automatici precedenti; +4. salva i nuovi prototype; +5. assegna `selection_order` a partire da 1. + +Per Jessica l'ordine validato è: + +```text +0 IMG_0145.JPG cluster_medoid +1 IMG_0141.JPG cluster_medoid +2 IMG_0193.JPG cluster_medoid +3 IMG_0143.JPG greedy_leave_one_out_gain +4 IMG_0146.JPG greedy_leave_one_out_gain +``` + +Il numero a sinistra è l'indice kNN, cioè `selection_order - 1`. + +### 5. Valutazioni Python ufficiali + +La soglia attuale è: + +```text +0.50 +``` + +Le valutazioni precedenti vengono sostituite atomicamente nella transazione SQLite. + +Il risultato Python viene salvato in `person_face_recognition_evaluations` ed è ancora il risultato ufficiale. + +### 6. Aggiornamento dello stato indice + +Dopo il ricalcolo dei prototype: + +```text +status = dirty +dirty = 1 +prototype_count = numero dei prototype attivi +last_error = null +``` + +### 7. Chiusura della transazione + +`persistResult()` conclude la transazione prima dell'avvio OpenCL. + +Questo evita di mantenere un lock SQLite durante: + +- creazione dei file binari; +- inizializzazione OpenCL; +- compilazione del kernel; +- esecuzione del kNN; +- scrittura del report shadow. + +### 8. Shadow OpenCL + +Dopo il commit della transazione, il worker chiama `runFaceKnnShadow()`. + +La sequenza è: + +```text +Risultato Python già salvato + | + v +Recupero embedding query nello stesso ordine Python + | + v +Recupero prototype ordinati per selection_order + | + v +Generazione queries.bin +Generazione prototypes.bin +Generazione self-map.txt + | + v +face_knn --backend opencl + | + v +Confronto Python contro OpenCL + | + v +Report shadow JSON +``` + +## Leave-one-out + +Se una query è anche un prototype, il confronto con sé stessa deve essere escluso. + +Il runtime costruisce una mappa tra `face_id` e indice del prototype. + +La self-map contiene: + +```text +-1 -> nessuna esclusione +0 -> escludi prototype indice 0 +1 -> escludi prototype indice 1 +2 -> escludi prototype indice 2 +... +``` + +Il kernel calcola il TOPK completo. L'esclusione viene applicata sul risultato host. + +## Output del motore nativo + +Il binario mantiene separati i flussi: + +```text +stdout -> JSON valido +stderr -> diagnostica OpenCL e messaggi del driver Mali +``` + +Il runtime Mali può scrivere direttamente: + +```text +arm_release_ver: g24p0-00eac0, rk_so_ver: 10 +``` + +La CLI devia temporaneamente stdout verso stderr durante OpenCL e lo ripristina prima di scrivere il JSON. + +## Controlli shadow + +Per ogni query vengono confrontati: + +- prototype migliore Python e OpenCL; +- best similarity; +- media dei primi tre score; +- decisione recognized; +- leave-one-out. + +Tolleranza configurata: + +```text +0.00001 +``` + +Campi aggregati del report: + +```text +query_count +prototype_count +top1_matches +decision_matches +best_score_matches +top3_mean_matches +maximum_score_error +maximum_top3_mean_error +mismatch_count +``` + +I report automatici vengono scritti in: + +```text +var/face-knn/shadow-JOB_ID.json +``` + +Il report non contiene embedding. + +## Gestione degli errori + +### Errore Python o persistenza + +```text +Il job viene marcato error. +last_error viene valorizzato. +Il worker restituisce exit code non zero. +``` + +### Errore OpenCL shadow + +```text +Il job Python resta done. +Le valutazioni ufficiali restano salvate. +L'errore viene registrato nel log shadow. +Non viene effettuato rollback. +``` + +### Mismatch Python/OpenCL + +```text +Il job Python resta done. +Il report contiene mismatch_count maggiore di zero. +OpenCL non viene promosso a motore ufficiale. +``` + +## Configurazione attuale + +File: + +```text +native/face-knn/config.json +``` + +Configurazione validata: + +```json +{ + "enabled": true, + "mode": "shadow", + "backend": "opencl", + "fallbackBackend": "cpu", + "dimension": 128, + "topK": 5, + "recognitionThreshold": 0.5, + "scoreTolerance": 0.00001, + "autoBackendMinimumComparisons": 100000, + "launcher": "native/face-knn/run-face-knn.sh", + "indexDirectory": "api_v1/face-recognition-index", + "runtimeDirectory": "var/face-knn" +} +``` + +## Validazione completata + +Persona validata: + +```text +User: Fabio +Persona: Jessica +Person ID: 77456100-d7b2-4c36-b3d6-09f8c1c9d7e7 +Query: 21 +Prototype: 5 +``` + +Risultato funzionale: + +```text +Riconosciuti: 20 +Sotto soglia: 1 +Copertura: 95.2% +Caso sotto soglia: IMG_0183.JPG +Score: 0.4664280414581299 +Miglior prototype: IMG_0143.JPG +Indice prototype: 3 +``` + +Risultato shadow automatico: + +```text +Top-1 identici: 21 / 21 +Decisioni identiche: 21 / 21 +Best score entro tolleranza: 21 / 21 +Top-3 mean entro tolleranza: 21 / 21 +Mismatch: 0 +Errore massimo best score: 1.7869088742017425e-7 +Errore massimo top-3 mean: 1.3911677043676463e-7 +``` + +Job validato: + +```text +Job ID: 7741c033-dae9-4b6f-b00f-bc082f08b8a1 +Stato: done +Tentativi: 1 +Reason: opencl_shadow_validation +Last error: null +``` + +## Stato attuale della produzione + +```text +Python: +- seleziona i prototype; +- calcola le valutazioni; +- salva il risultato ufficiale. + +OpenCL: +- ricalcola il kNN dopo il commit; +- confronta il risultato; +- genera il report shadow; +- non modifica il risultato ufficiale. +``` + +## Prossimi passi + +1. creare il builder dell'indice persistente; +2. generare atomicamente embeddings e metadati; +3. portare `face_recognition_index_state` da dirty a ready; +4. mantenere la modalità shadow per più job reali; +5. definire criteri di promozione OpenCL; +6. mantenere il backend CPU come fallback; +7. passare a OpenCL ufficiale solo dopo validazioni sufficienti. + +## Comandi operativi utili + +### Controllare i job pending + +```bash +sqlite3 -header -column \ + api_v1/database.sqlite \ + " + SELECT + id, + user, + person_id, + status, + reason, + requested_at + FROM face_prototype_jobs + WHERE status = 'pending' + ORDER BY requested_at; + " +``` + +### Eseguire un job specifico + +```bash +node tools/runPrototypeWorker.js \ + --persist \ + --job-id "JOB_ID" +``` + +### Validare un report shadow + +```bash +python3 -m json.tool \ + "var/face-knn/shadow-JOB_ID.json" \ + > /dev/null \ + && echo "OK: report shadow valido" +``` + +### Controllare le valutazioni ufficiali + +```bash +sqlite3 -header -column \ + api_v1/database.sqlite \ + " + SELECT + COUNT(*) AS evaluations, + SUM(CASE WHEN recognized = 1 THEN 1 ELSE 0 END) + AS recognized_faces, + SUM(CASE WHEN recognized = 0 THEN 1 ELSE 0 END) + AS below_threshold + FROM person_face_recognition_evaluations; + " +``` + +### Controllare lo stato dell'indice + +```bash +sqlite3 -header -column \ + api_v1/database.sqlite \ + " + SELECT + user, + status, + dirty, + version, + prototype_count, + index_path, + metadata_path, + built_at, + last_error + FROM face_recognition_index_state + WHERE user = 'Fabio'; + " +``` + +## Conversione in Markdown standard + +Per generare un file Markdown normale: + +```bash +sed \ + -e 's/^__H4__[[:space:]]*/#### /' \ + -e 's/^__H3__[[:space:]]*/### /' \ + -e 's/^__H2__[[:space:]]*/## /' \ + -e 's/^__H1__[[:space:]]*/# /' \ + -e 's/^__FENCE_TEXT__$/```text/' \ + -e 's/^__FENCE_BASH__$/```bash/' \ + -e 's/^__FENCE_JSON__$/```json/' \ + -e 's/^__FENCE_JS__$/```javascript/' \ + -e 's/^__FENCE__$/```/' \ + -e 's/`/`/g' \ + RICONOSCIMENTO_SERVER.src.md \ + > RICONOSCIMENTO_SERVER.md +``` + +Verifica che non siano rimasti segnaposto: + +```bash +grep -nE \ + '__H[1-4]__|__FENCE|`' \ + RICONOSCIMENTO_SERVER.md \ + || echo "OK: segnaposto convertiti" +``` diff --git a/docs/activity_scan.md b/docs/activity_scan.md new file mode 100644 index 0000000..4be5742 --- /dev/null +++ b/docs/activity_scan.md @@ -0,0 +1,341 @@ +Certo. Dopo le modifiche in s179js, il flusso Activity è ormai molto simile a quello Photo, ma la notifica alla gallery segue ancora il percorso tramite POST /activities. + +1. Flusso attuale di scansione Activity + +Quando premi Scan Activities: + + UTENTE + │ + ▼ + Scan Activities + │ + ▼ + routes/scanActivities.js + │ + ▼ + scanActivitiesUser() + │ + trova cartelle Activity + │ + ▼ + scanActivity() + │ + ▼ + scanActivityCartella() + │ + trova FIT/GPX/TCX + │ + ▼ + enqueueActivityIngestJob() + │ + ▼ + activity_ingest_jobs + │ + ┌─────────────┼──────────────┐ + │ │ │ + pending processing done + │ + ▼ + │ ACTIVITY DAEMON [A] + └────────────►│ + ▼ + runOneJob() + │ + ▼ + scanActivitySingle() + │ + ▼ + processActivityFile() + │ + parsing FIT/GPX/TCX + │ + ▼ + activities DB + │ + ├───────────────┐ + │ │ + ▼ ▼ + geocode_jobs ritorno meta + │ + ▼ + GEOCODE DAEMON [G] + │ + ▼ + geocoding + +Lo Scan adesso è solo il produttore + +Questa è la differenza fondamentale rispetto a prima. + +Prima: + +Scan + ↓ +trova file + ↓ +parsa FIT + ↓ +salva Activity + ↓ +file successivo + + +Adesso: + +Scan + ↓ +trova file + ↓ +mette job pending + ↓ +file successivo + + +Per questo nel log vedi velocemente: + +[S] 📥 [ACTIVITY QUEUE] xxx.fit status=pending inserted +[S] 📥 [ACTIVITY QUEUE] yyy.fit status=pending inserted +[S] 📥 [ACTIVITY QUEUE] zzz.fit status=pending inserted + + +e poi: + +[S] Scan ATTIVITÀ COMPLETATO + + +Questo però non significa che tutte le Activity siano elaborate. Significa solamente che la fase di discovery ha finito di riempire la coda. + +2. Il daemon svuota la coda + +Parallelamente [A] fa: + +activity_ingest_jobs + │ + ▼ +prende primo pending + │ + ▼ +processing + │ + ▼ +parse FIT + │ + ▼ +salva Activity + │ + ▼ +accoda Geocode + │ + ▼ +sincronizza server + │ + ▼ +done + │ + ▼ +prende IMMEDIATAMENTE +il pending successivo + + +Infatti nel tuo log: + +[A] Avvio job ... +[A] Nuova activity ... +[A] Activity ... inviata al server remoto +[A] Job ... completato; stato: done +[A] Avvio job successivo ... + + +Non ci sono 2 secondi tra un'attività e l'altra. + +I 2 secondi servono solo quando: + +pending = 0 + +3. Geocode è completamente indipendente + +Quando scanActivitySingle() salva l'Activity esegue anche: + +enqueueGeocodeJob + + +quindi: + +ACTIVITY DAEMON + │ + ▼ +activities DB + │ + └────────────► geocode_jobs + │ + ▼ + GEOCODE DAEMON [G] + │ + ▼ + geocoding + + +Activity daemon non aspetta che il Geocode finisca. + +Per questo nel log puoi vedere: + +[A] job Activity successivo + + +mentre: + +[G] geocodeWorker processing + + +sta ancora lavorando sull'attività precedente. + +4. Flusso attuale di notifica alla gallery Activity + +Qui il percorso è leggermente diverso da Photo. + +Quando scanActivitySingle() restituisce la nuova Activity: + + ACTIVITY DAEMON [A] + │ + ▼ + scannedActivity/meta + │ + ▼ + postWithAuth() + │ + ▼ + POST BASE_URL/activities + │ + ▼ + routes/activities.js + │ + ▼ + INSERT activity + ON CONFLICT MERGE + │ + ▼ + invalidateActivitySpatialIndex() + │ + ▼ + broadcastToUser() + │ + payload: + type: "added" + id: activityId + entity: "activity" + │ + ▼ + WebSocket + │ + ▼ + CLIENT + │ + ▼ + Activity Gallery + + +Quindi la notifica alla gallery non viene inviata direttamente dall'Activity daemon. + +Il daemon fa: + +Activity daemon + ↓ +HTTP POST + ↓ +server + ↓ +WebSocket + ↓ +Gallery + + +Questo è importante perché il processo [A] non importa ws-server e quindi non rischia di aprire un secondo WebSocket sulla porta 4002. + +5. Pipeline completa + +Mettendo insieme tutto: + + SCAN + │ + ▼ + trova FIT/GPX/TCX + │ + ▼ + activity_ingest_jobs + │ + │ pending + ▼ + ACTIVITY DAEMON [A] + │ + ▼ + parse FIT/GPX/TCX + │ + ▼ + activities DB + │ + ┌────────────┴─────────────┐ + │ │ + ▼ ▼ + geocode_jobs activity meta + │ │ + ▼ ▼ + GEOCODE DAEMON [G] POST /activities + │ + ▼ + SERVER + │ + INSERT / UPDATE + │ + ▼ + WebSocket + │ + ▼ + ACTIVITY GALLERY + + Activity daemon + │ + ▼ + job status = done + │ + ▼ + prossimo pending + +6. Cosa significa per il futuro banner + +Ora abbiamo finalmente una coda affidabile: + +activity_ingest_jobs + + +quindi il banner Activity non deve più basarsi sul vecchio: + +scan_status.json + + +Può mostrare direttamente: + +remaining = pending + processing + + +Per esempio: + +Activities processing: 27 + + +poi: + +Activities processing: 26 +Activities processing: 25 +... +Activities processing: 1 + + +e quando: + +pending = 0 +processing = 0 + + +il banner scompare. + +Questo sarebbe molto più corretto del vecchio progress dello Scan, perché lo Scan può aver finito di accodare i file mentre l'Activity daemon ha ancora 30 FIT da elaborare. diff --git a/docs/cast.md b/docs/cast.md new file mode 100644 index 0000000..20bd3c2 --- /dev/null +++ b/docs/cast.md @@ -0,0 +1,15 @@ +./transcode_rk_vod.sh ../IMG_1425.MOV ../fabiocast +python3 hls_cors_server.py ../fabiocast -p 9999 +catt -d 192.168.1.15 cast http://192.168.1.4:9999/master.m3u8 + + +./transcode_rk_vod.sh \ + ../IMG_1425.MOV \ + ../fabiocast + +python3 hls_cors_server.py \ + ../fabiocast \ + -p 9999 + +catt -d 192.168.1.15 cast \ + http://192.168.1.4:9999/master.m3u8 diff --git a/docs/come funziona.md b/docs/come funziona.md new file mode 100644 index 0000000..e24d1cc --- /dev/null +++ b/docs/come funziona.md @@ -0,0 +1,202 @@ +Perfetto Fabio — ti preparo **la guida completa, chiara e pratica** per integrare il tuo **viewer Flutter Android** con il **WebSocket del tuo server Node.js**, così come funziona oggi nel tuo backend. + +Ti spiego: + +1. **Come si autentica il client Flutter** +2. **Come si connette al WebSocket** +3. **Quali messaggi riceve** (formato esatto) +4. **Come deve rispondere** +5. **Come integrare tutto nel viewer** + +Tutto basato sul tuo backend reale. + +--- + +# ⭐ 1. COME SI AUTENTICA IL CLIENT FLUTTER + +Il tuo server usa JWT. + +### Passi: + +1. Flutter fa login via HTTP: + +``` +POST /auth/login +{ + "email": "...", + "password": "..." +} +``` + +2. Il server risponde: + +```json +{ + "token": "", + "name": "Fabio" +} +``` + +3. Flutter salva il token. + +4. **Ogni chiamata HTTP successiva** deve avere: + +``` +Authorization: Bearer +``` + +--- + +# ⭐ 2. COME SI CONNETTE AL WEBSOCKET + +Il tuo server WebSocket accetta connessioni così: + +``` +ws://:/?token= +``` + +Quindi in Flutter: + +```dart +import 'package:web_socket_channel/web_socket_channel.dart'; + +final channel = WebSocketChannel.connect( + Uri.parse("ws://192.168.1.10:4000/?token=$jwt"), +); +``` + +### Importante: +- Devi passare **lo stesso JWT** usato per le API HTTP. +- Il server autentica il token e associa la connessione all’utente. + +--- + +# ⭐ 3. QUALI MESSAGGI RICEVE FLUTTER (FORMATO ESATTO) + +Il tuo backend invia questi eventi: + +### 📌 Quando una foto viene aggiunta +```json +{ + "type": "added", + "id": "12345" +} +``` + +### 📌 Quando una foto viene rimossa (DEL) +```json +{ + "type": "removed", + "id": "12345" +} +``` + +### 📌 Quando una cartella viene aggiunta (ADD_DIR) +```json +{ + "type": "add_dir", + "folder": "Vacanze/2024" +} +``` + +### 📌 Quando una cartella viene rimossa (DEL_DIR) +```json +{ + "type": "del_dir", + "folder": "Vacanze/2024" +} +``` + +### 📌 Quando una foto viene soft-deleted / restored +```json +{ + "type": "updated", + "id": "12345", + "deleted_at": "2026-03-31T10:00:00Z" // oppure null +} +``` + +--- + +# ⭐ 4. COME FLUTTER DEVE GESTIRE I MESSAGGI + +Esempio completo: + +```dart +channel.stream.listen((message) { + final data = jsonDecode(message); + + switch (data["type"]) { + case "added": + // Scarica la foto via /photos/:id + fetchPhoto(data["id"]); + break; + + case "removed": + // Rimuovi la foto dalla UI + removePhotoFromUI(data["id"]); + break; + + case "updated": + // Aggiorna soft delete + updatePhotoState(data["id"], data["deleted_at"]); + break; + + case "add_dir": + // Aggiorna lista cartelle + addFolderToUI(data["folder"]); + break; + + case "del_dir": + // Rimuovi cartella dalla UI + removeFolderFromUI(data["folder"]); + break; + } +}); +``` + +--- + +# ⭐ 5. COME DEVE RISPONDERE FLUTTER AL SERVER + +Il tuo server **non richiede risposte** dal client WebSocket. + +È **one-way**: + +- Il server invia eventi +- Il client li riceve +- Il client aggiorna la UI + +Flutter **non deve inviare nulla** al WebSocket. + +--- + +# ⭐ 6. COME INTEGRARE TUTTO NEL VIEWER + +### Al login: +- fai login HTTP +- salva il JWT +- apri il WebSocket + +### All’avvio del viewer: +1. Carica tutte le foto via: + ``` + GET /photos + ``` +2. Avvia il WebSocket +3. Applica in tempo reale: + - added → aggiungi foto + - removed → rimuovi foto + - updated → aggiorna stato + - add_dir → aggiungi cartella + - del_dir → rimuovi cartella + +### Dopo 2 minuti (quando WS non è più affidabile): +- usa progressive sync: + +``` +GET /photos/changes?since= +GET /photos/deleted_hard?since= +``` + +--- \ No newline at end of file diff --git a/docs/face-knn-source/COMPILAZIONE_FACE_KNN.md b/docs/face-knn-source/COMPILAZIONE_FACE_KNN.md new file mode 100644 index 0000000..dae297f --- /dev/null +++ b/docs/face-knn-source/COMPILAZIONE_FACE_KNN.md @@ -0,0 +1,207 @@ +# Face KNN OpenCL - Note di compilazione + +## Repository + +Directory sorgente: + +/home/nvme/dockerdata/prove/face-clustering-rk3588-knn-opencl-validated + +Questo repository contiene i sorgenti del motore KNN utilizzato dai progetti s1xxjs/s190js. + +Il binario distribuito è: + +bin/face_knn + +SHA256 del binario originale: + +e74bd672eebaa0f10028e25c0f7051e981cd16c2eeb9c529f513a6c412c11033 + +--- + +## File principali + +### Motore KNN + +src/face_knn.cc + +Gestisce: + +- parsing input +- caricamento embeddings +- scelta CPU/OpenCL +- output risultati + +### Backend OpenCL + +src/knn_opencl.cc + +Implementazione KNN GPU. + +### Integrazione Mali G610 + +src/knn_opencl_backend.cc + +Gestisce: + +- ricerca device OpenCL +- compilazione kernel +- allocazione buffer +- esecuzione GPU + +### Kernel GPU + +kernels/knn_float4_topk.cl + +Kernel OpenCL eseguito sulla Mali G610. + +--- + +## Verifica binario + +Verifica hash: + +sha256sum bin/face_knn + +Hash atteso: + +e74bd672eebaa0f10028e25c0f7051e981cd16c2eeb9c529f513a6c412c11033 + +--- + +## Librerie richieste + +Il binario utilizza: + +- libOpenCL.so.1 +- libstdc++.so.6 +- libgcc_s.so.1 +- libc.so.6 +- libm.so.6 + +Verifica: + +ldd bin/face_knn + +--- + +## Simboli principali + +Verifica presenza delle funzioni: + +nm -C bin/face_knn | grep search_opencl +nm -C bin/face_knn | grep search_cpu + +Devono comparire: + +face_knn::search_opencl() +face_knn::search_opencl_impl() +face_knn::search_cpu() + +--- + +## Compilazione (da verificare) + +La build originale non è presente nel Makefile attuale. + +Comando ricostruito: + +g++ \ + -std=c++17 \ + -O2 \ + -Wall \ + -Wextra \ + -Wpedantic \ + -Iinclude \ + src/face_knn.cc \ + src/knn_opencl.cc \ + src/knn_opencl_backend.cc \ + -lOpenCL \ + -ldl \ + -lpthread \ + -o bin/face_knn + +NOTA: +Prima di utilizzarlo verificare eventuali dipendenze mancanti. + +--- + +## Evidenze raccolte + +Il binario contiene riferimenti diretti a: + +- face_knn.cc +- knn_opencl.cc +- knn_opencl_backend.cc + +Verifica: + +strings bin/face_knn | grep -i "face_knn.cc\\|knn_opencl" + +Il binario esporta: + +- face_knn::search_opencl() +- face_knn::search_opencl_impl() +- face_knn::search_cpu() + +Verifica: + +nm -C bin/face_knn | grep search_ + +--- + +## Utilizzo nei progetti NodeJS + +Il binario è utilizzato da: + +native/face-knn/bin/face_knn + +richiamato tramite: + +tools/faceKnnRuntime.js + +nei progetti s141js → s190js. + + +## Provenienza verificata + +Il binario utilizzato nei progetti s141js → s190js: + +native/face-knn/bin/face_knn + +ha SHA256: + +e74bd672eebaa0f10028e25c0f7051e981cd16c2eeb9c529f513a6c412c11033 + +Lo stesso hash è stato trovato nei repository: + +- face-recognition-rk3588-opencl +- face-clustering-rk3588-knn-opencl +- face-clustering-rk3588-knn-opencl-validated + +Verifiche effettuate: + +- strings bin/face_knn +- nm -C bin/face_knn +- ldd bin/face_knn +- sha256sum bin/face_knn + +Risultato: + +Il binario deriva dai sorgenti: + +- src/face_knn.cc +- src/knn_opencl.cc +- src/knn_opencl_backend.cc + +e utilizza il kernel: + +- kernels/knn_float4_topk.cl + +Target GPU: + +- ARM Mali-G610 (RK3588 / Orange Pi 5 Plus) + +Data documentazione: + +2026-10-26 + diff --git a/docs/face-knn-source/SHA256SUMS b/docs/face-knn-source/SHA256SUMS new file mode 100644 index 0000000..420ac7a --- /dev/null +++ b/docs/face-knn-source/SHA256SUMS @@ -0,0 +1 @@ +e74bd672eebaa0f10028e25c0f7051e981cd16c2eeb9c529f513a6c412c11033 bin/face_knn diff --git a/docs/face-knn-source/bin/.gitkeep b/docs/face-knn-source/bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/face-knn-source/bin/face_knn b/docs/face-knn-source/bin/face_knn new file mode 100755 index 0000000..e2b04d6 Binary files /dev/null and b/docs/face-knn-source/bin/face_knn differ diff --git a/docs/face-knn-source/bin/face_recognition b/docs/face-knn-source/bin/face_recognition new file mode 100755 index 0000000..581eac3 Binary files /dev/null and b/docs/face-knn-source/bin/face_recognition differ diff --git a/docs/face-knn-source/bin/face_scan b/docs/face-knn-source/bin/face_scan new file mode 100755 index 0000000..008171e Binary files /dev/null and b/docs/face-knn-source/bin/face_scan differ diff --git a/docs/face-knn-source/bin/test_knn_cpu b/docs/face-knn-source/bin/test_knn_cpu new file mode 100755 index 0000000..6b443ca Binary files /dev/null and b/docs/face-knn-source/bin/test_knn_cpu differ diff --git a/docs/face-knn-source/include/face_config.h b/docs/face-knn-source/include/face_config.h new file mode 100644 index 0000000..bd1f0f5 --- /dev/null +++ b/docs/face-knn-source/include/face_config.h @@ -0,0 +1,5 @@ +#pragma once + +#ifndef FACE_CLUSTERING_ROOT +#define FACE_CLUSTERING_ROOT "." +#endif diff --git a/docs/face-knn-source/include/face_engine.h b/docs/face-knn-source/include/face_engine.h new file mode 100644 index 0000000..b646335 --- /dev/null +++ b/docs/face-knn-source/include/face_engine.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +struct Point { + float x = 0.0f; + float y = 0.0f; +}; + +struct Face { + float score = 0.0f; + + float x1 = 0.0f; + float y1 = 0.0f; + float x2 = 0.0f; + float y2 = 0.0f; + + Point kps[5]{}; + + // SFace: embedding L2-normalizzato da 128 float32. + std::vector embedding; +}; + +class FaceEngine { +public: + FaceEngine(); + ~FaceEngine(); + + FaceEngine(const FaceEngine&) = delete; + FaceEngine& operator=(const FaceEngine&) = delete; + + bool init( + const std::string& scrfd_model, + const std::string& sface_model); + + bool process_image( + const std::string& image_path, + std::vector& faces, + int& image_width, + int& image_height, + int rotation_degrees = 0); + +private: + struct Impl; + Impl* impl_; +}; diff --git a/docs/face-knn-source/include/knn_opencl.h b/docs/face-knn-source/include/knn_opencl.h new file mode 100644 index 0000000..db9722d --- /dev/null +++ b/docs/face-knn-source/include/knn_opencl.h @@ -0,0 +1,121 @@ +#ifndef FACE_CLUSTERING_KNN_OPENCL_H +#define FACE_CLUSTERING_KNN_OPENCL_H + +#include +#include +#include +#include + +namespace face_knn { + +constexpr std::uint32_t invalid_index = + 0xffffffffu; + + +struct Neighbor { + float score = 0.0f; + + std::uint32_t index = + invalid_index; +}; + + +struct SearchOptions { + std::uint32_t dimension = 128; + + std::uint32_t top_k = 1; + + std::uint32_t lanes_per_query = 8; + + std::size_t chunk_size = 50000; + + std::string kernel_path; +}; + + +struct SearchOutput { + std::size_t query_count = 0; + + std::size_t database_count = 0; + + std::uint32_t dimension = 0; + + std::uint32_t top_k = 0; + + std::string backend; + + std::string device_name; + + double elapsed_ms = 0.0; + + /* + * Layout: + * + * neighbors[ + * query_index * top_k + * + neighbor_position + * ] + */ + std::vector neighbors; +}; + + +struct ValidationResult { + std::size_t positions = 0; + + std::size_t exact_index_matches = 0; + + std::size_t decision_matches = 0; + + float maximum_score_error = 0.0f; + + bool exact_indices = false; + + bool decisions_equal = false; +}; + + +void validate_input( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options); + + +SearchOutput search_cpu( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options); + + +SearchOutput search_opencl( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options); + + +ValidationResult compare_outputs( + const SearchOutput& expected, + const SearchOutput& actual, + float score_tolerance, + float recognition_threshold); + + +std::vector query_neighbors( + const SearchOutput& output, + std::size_t query_index); + + +std::vector exclude_neighbor( + const std::vector& neighbors, + std::uint32_t excluded_index); + + +} // namespace face_knn + +#endif diff --git a/docs/face-knn-source/include/rknn_api.h b/docs/face-knn-source/include/rknn_api.h new file mode 100644 index 0000000..6b294d2 --- /dev/null +++ b/docs/face-knn-source/include/rknn_api.h @@ -0,0 +1,804 @@ +/**************************************************************************** +* +* Copyright (c) 2017 - 2022 by Rockchip Corp. All rights reserved. +* +* The material in this file is confidential and contains trade secrets +* of Rockchip Corporation. This is proprietary information owned by +* Rockchip Corporation. No part of this work may be disclosed, +* reproduced, copied, transmitted, or used in any way for any purpose, +* without the express written permission of Rockchip Corporation. +* +*****************************************************************************/ + + +#ifndef _RKNN_API_H +#define _RKNN_API_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +/* + Definition of extended flag for rknn_init. +*/ +/* set high priority context. */ +#define RKNN_FLAG_PRIOR_HIGH 0x00000000 + +/* set medium priority context */ +#define RKNN_FLAG_PRIOR_MEDIUM 0x00000001 + +/* set low priority context. */ +#define RKNN_FLAG_PRIOR_LOW 0x00000002 + +/* asynchronous mode. + when enable, rknn_outputs_get will not block for too long because it directly retrieves the result of + the previous frame which can increase the frame rate on single-threaded mode, but at the cost of + rknn_outputs_get not retrieves the result of the current frame. + in multi-threaded mode you do not need to turn this mode on. */ +#define RKNN_FLAG_ASYNC_MASK 0x00000004 + +/* collect performance mode. + when enable, you can get detailed performance reports via rknn_query(ctx, RKNN_QUERY_PERF_DETAIL, ...), + but it will reduce the frame rate. */ +#define RKNN_FLAG_COLLECT_PERF_MASK 0x00000008 + +/* allocate all memory in outside, includes weight/internal/inputs/outputs */ +#define RKNN_FLAG_MEM_ALLOC_OUTSIDE 0x00000010 + +/* weight sharing with the same network structure */ +#define RKNN_FLAG_SHARE_WEIGHT_MEM 0x00000020 + +/* send fence fd from outside */ +#define RKNN_FLAG_FENCE_IN_OUTSIDE 0x00000040 + +/* get fence fd from inside */ +#define RKNN_FLAG_FENCE_OUT_OUTSIDE 0x00000080 + +/* dummy init flag: could only get total_weight_size and total_internal_size by rknn_query*/ +#define RKNN_FLAG_COLLECT_MODEL_INFO_ONLY 0x00000100 + +/* allocate internal memory in outside */ +#define RKNN_FLAG_INTERNAL_ALLOC_OUTSIDE 0x00000200 + +/* set GPU as the preferred execution backend When the operator is not supported by the NPU */ +#define RKNN_FLAG_EXECUTE_FALLBACK_PRIOR_DEVICE_GPU 0x00000400 + +/* enable allocate sram type buffers */ +#define RKNN_FLAG_ENABLE_SRAM 0x00000800 + +/* sram type buffers are shared among different contexts */ +#define RKNN_FLAG_SHARE_SRAM 0x00001000 + +/* default nice -19, this flag can disable default priority */ +#define RKNN_FLAG_DISABLE_PROC_HIGH_PRIORITY 0x00002000 + +/* don't flush input buffer cache, the user must ensure that the input tensor has flushed the cache before calling rknn_run. +!!! Don't use this flags when you call rknn_inputs_set() to set input data. */ +#define RKNN_FLAG_DISABLE_FLUSH_INPUT_MEM_CACHE 0x00004000 + +/* Don't invalid output buffer cache. + Users cannot directly access output_mem->virt_addr, + which will cause cache consistency problems. + If you want to use output_mem->virt_addr, + you must use rknn_mem_sync (ctx, mem, RKNN_MEMORY_SYNC_FROM_DEVICE) to flush the cache. + This flags is generally used when the output data of the NPU is not accessed by the CPU, + but is accessed by the GPU or RGA to reduce the time required to flush the cache. + !!! Don't use this flags when you call rknn_outputs_get() to get output data.*/ +#define RKNN_FLAG_DISABLE_FLUSH_OUTPUT_MEM_CACHE 0x00008000 + +/* This flag is used when the model data buffer is allocated by NPU, and can be accessed by NPU directly. */ +#define RKNN_FLAG_MODEL_BUFFER_ZERO_COPY 0x00010000 + +/* This flag is a memory allocation flag, which is used in rknn_create_mem2() when no context is available. */ +#define RKNN_MEM_FLAG_ALLOC_NO_CONTEXT 0x00020000 + + +/* + Error code returned by the RKNN API. +*/ +#define RKNN_SUCC 0 /* execute succeed. */ +#define RKNN_ERR_FAIL -1 /* execute failed. */ +#define RKNN_ERR_TIMEOUT -2 /* execute timeout. */ +#define RKNN_ERR_DEVICE_UNAVAILABLE -3 /* device is unavailable. */ +#define RKNN_ERR_MALLOC_FAIL -4 /* memory malloc fail. */ +#define RKNN_ERR_PARAM_INVALID -5 /* parameter is invalid. */ +#define RKNN_ERR_MODEL_INVALID -6 /* model is invalid. */ +#define RKNN_ERR_CTX_INVALID -7 /* context is invalid. */ +#define RKNN_ERR_INPUT_INVALID -8 /* input is invalid. */ +#define RKNN_ERR_OUTPUT_INVALID -9 /* output is invalid. */ +#define RKNN_ERR_DEVICE_UNMATCH -10 /* the device is unmatch, please update rknn sdk + and npu driver/firmware. */ +#define RKNN_ERR_INCOMPATILE_PRE_COMPILE_MODEL -11 /* This RKNN model use pre_compile mode, but not compatible with current driver. */ +#define RKNN_ERR_INCOMPATILE_OPTIMIZATION_LEVEL_VERSION -12 /* This RKNN model set optimization level, but not compatible with current driver. */ +#define RKNN_ERR_TARGET_PLATFORM_UNMATCH -13 /* This RKNN model set target platform, but not compatible with current platform. */ + +/* + Definition for tensor +*/ +#define RKNN_MAX_DIMS 16 /* maximum dimension of tensor. */ +#define RKNN_MAX_NUM_CHANNEL 15 /* maximum channel number of input tensor. */ +#define RKNN_MAX_NAME_LEN 256 /* maximum name lenth of tensor. */ +#define RKNN_MAX_DYNAMIC_SHAPE_NUM 512 /* maximum number of dynamic shape for each input. */ + +#ifdef __arm__ +typedef uint32_t rknn_context; +#else +typedef uint64_t rknn_context; +#endif + + +/* + The query command for rknn_query +*/ +typedef enum _rknn_query_cmd { + RKNN_QUERY_IN_OUT_NUM = 0, /* query the number of input & output tensor. */ + RKNN_QUERY_INPUT_ATTR = 1, /* query the attribute of input tensor. */ + RKNN_QUERY_OUTPUT_ATTR = 2, /* query the attribute of output tensor. */ + RKNN_QUERY_PERF_DETAIL = 3, /* query the detail performance, need set + RKNN_FLAG_COLLECT_PERF_MASK when call rknn_init, + this query needs to be valid after rknn_outputs_get. */ + RKNN_QUERY_PERF_RUN = 4, /* query the time of run, + this query needs to be valid after rknn_outputs_get. */ + RKNN_QUERY_SDK_VERSION = 5, /* query the sdk & driver version */ + + RKNN_QUERY_MEM_SIZE = 6, /* query the weight & internal memory size */ + RKNN_QUERY_CUSTOM_STRING = 7, /* query the custom string */ + + RKNN_QUERY_NATIVE_INPUT_ATTR = 8, /* query the attribute of native input tensor. */ + RKNN_QUERY_NATIVE_OUTPUT_ATTR = 9, /* query the attribute of native output tensor. */ + + RKNN_QUERY_NATIVE_NC1HWC2_INPUT_ATTR = 8, /* query the attribute of native input tensor. */ + RKNN_QUERY_NATIVE_NC1HWC2_OUTPUT_ATTR = 9, /* query the attribute of native output tensor. */ + + RKNN_QUERY_NATIVE_NHWC_INPUT_ATTR = 10, /* query the attribute of native input tensor. */ + RKNN_QUERY_NATIVE_NHWC_OUTPUT_ATTR = 11, /* query the attribute of native output tensor. */ + + RKNN_QUERY_DEVICE_MEM_INFO = 12, /* query the attribute of rknn memory information. */ + + RKNN_QUERY_INPUT_DYNAMIC_RANGE = 13, /* query the dynamic shape range of rknn input tensor. */ + RKNN_QUERY_CURRENT_INPUT_ATTR = 14, /* query the current shape of rknn input tensor, only valid for dynamic rknn model*/ + RKNN_QUERY_CURRENT_OUTPUT_ATTR = 15, /* query the current shape of rknn output tensor, only valid for dynamic rknn model*/ + + RKNN_QUERY_CURRENT_NATIVE_INPUT_ATTR = 16, /* query the current native shape of rknn input tensor, only valid for dynamic rknn model*/ + RKNN_QUERY_CURRENT_NATIVE_OUTPUT_ATTR = 17, /* query the current native shape of rknn output tensor, only valid for dynamic rknn model*/ + + + RKNN_QUERY_CMD_MAX +} rknn_query_cmd; + +/* + the tensor data type. +*/ +typedef enum _rknn_tensor_type { + RKNN_TENSOR_FLOAT32 = 0, /* data type is float32. */ + RKNN_TENSOR_FLOAT16, /* data type is float16. */ + RKNN_TENSOR_INT8, /* data type is int8. */ + RKNN_TENSOR_UINT8, /* data type is uint8. */ + RKNN_TENSOR_INT16, /* data type is int16. */ + RKNN_TENSOR_UINT16, /* data type is uint16. */ + RKNN_TENSOR_INT32, /* data type is int32. */ + RKNN_TENSOR_UINT32, /* data type is uint32. */ + RKNN_TENSOR_INT64, /* data type is int64. */ + RKNN_TENSOR_BOOL, + RKNN_TENSOR_INT4, + RKNN_TENSOR_BFLOAT16, + + RKNN_TENSOR_TYPE_MAX +} rknn_tensor_type; + +inline static const char* get_type_string(rknn_tensor_type type) +{ + switch(type) { + case RKNN_TENSOR_FLOAT32: return "FP32"; + case RKNN_TENSOR_FLOAT16: return "FP16"; + case RKNN_TENSOR_INT8: return "INT8"; + case RKNN_TENSOR_UINT8: return "UINT8"; + case RKNN_TENSOR_INT16: return "INT16"; + case RKNN_TENSOR_UINT16: return "UINT16"; + case RKNN_TENSOR_INT32: return "INT32"; + case RKNN_TENSOR_UINT32: return "UINT32"; + case RKNN_TENSOR_INT64: return "INT64"; + case RKNN_TENSOR_BOOL: return "BOOL"; + case RKNN_TENSOR_INT4: return "INT4"; + case RKNN_TENSOR_BFLOAT16: return "BF16"; + default: return "UNKNOW"; + } +} + +/* + the quantitative type. +*/ +typedef enum _rknn_tensor_qnt_type { + RKNN_TENSOR_QNT_NONE = 0, /* none. */ + RKNN_TENSOR_QNT_DFP, /* dynamic fixed point. */ + RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC, /* asymmetric affine. */ + + RKNN_TENSOR_QNT_MAX +} rknn_tensor_qnt_type; + +inline static const char* get_qnt_type_string(rknn_tensor_qnt_type type) +{ + switch(type) { + case RKNN_TENSOR_QNT_NONE: return "NONE"; + case RKNN_TENSOR_QNT_DFP: return "DFP"; + case RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC: return "AFFINE"; + default: return "UNKNOW"; + } +} + +/* + the tensor data format. +*/ +typedef enum _rknn_tensor_format { + RKNN_TENSOR_NCHW = 0, /* data format is NCHW. */ + RKNN_TENSOR_NHWC, /* data format is NHWC. */ + RKNN_TENSOR_NC1HWC2, /* data format is NC1HWC2. */ + RKNN_TENSOR_UNDEFINED, + + RKNN_TENSOR_FORMAT_MAX +} rknn_tensor_format; + +/* + the mode of running on target NPU core. +*/ +typedef enum _rknn_core_mask { + RKNN_NPU_CORE_AUTO = 0, /* default, run on NPU core randomly. */ + RKNN_NPU_CORE_0 = 1, /* run on NPU core 0. */ + RKNN_NPU_CORE_1 = 2, /* run on NPU core 1. */ + RKNN_NPU_CORE_2 = 4, /* run on NPU core 2. */ + RKNN_NPU_CORE_0_1 = RKNN_NPU_CORE_0 | RKNN_NPU_CORE_1, /* run on NPU core 0 and core 1. */ + RKNN_NPU_CORE_0_1_2 = RKNN_NPU_CORE_0_1 | RKNN_NPU_CORE_2, /* run on NPU core 0 and core 1 and core 2. */ + RKNN_NPU_CORE_ALL = 0xffff, /* auto choice, run on NPU cores depending on platform */ + + RKNN_NPU_CORE_UNDEFINED, +} rknn_core_mask; + +inline static const char* get_format_string(rknn_tensor_format fmt) +{ + switch(fmt) { + case RKNN_TENSOR_NCHW: return "NCHW"; + case RKNN_TENSOR_NHWC: return "NHWC"; + case RKNN_TENSOR_NC1HWC2: return "NC1HWC2"; + case RKNN_TENSOR_UNDEFINED: return "UNDEFINED"; + default: return "UNKNOW"; + } +} + +/* + the information for RKNN_QUERY_IN_OUT_NUM. +*/ +typedef struct _rknn_input_output_num { + uint32_t n_input; /* the number of input. */ + uint32_t n_output; /* the number of output. */ +} rknn_input_output_num; + +/* + the information for RKNN_QUERY_INPUT_ATTR / RKNN_QUERY_OUTPUT_ATTR. +*/ +typedef struct _rknn_tensor_attr { + uint32_t index; /* input parameter, the index of input/output tensor, + need set before call rknn_query. */ + + uint32_t n_dims; /* the number of dimensions. */ + uint32_t dims[RKNN_MAX_DIMS]; /* the dimensions array. */ + char name[RKNN_MAX_NAME_LEN]; /* the name of tensor. */ + + uint32_t n_elems; /* the number of elements. */ + uint32_t size; /* the bytes size of tensor. */ + + rknn_tensor_format fmt; /* the data format of tensor. */ + rknn_tensor_type type; /* the data type of tensor. */ + rknn_tensor_qnt_type qnt_type; /* the quantitative type of tensor. */ + int8_t fl; /* fractional length for RKNN_TENSOR_QNT_DFP. */ + int32_t zp; /* zero point for RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC. */ + float scale; /* scale for RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC. */ + + uint32_t w_stride; /* the stride of tensor along the width dimention of input, + Note: it is read-only, 0 means equal to width. */ + uint32_t size_with_stride; /* the bytes size of tensor with stride. */ + + uint8_t pass_through; /* pass through mode, for rknn_set_io_mem interface. + if TRUE, the buf data is passed directly to the input node of the rknn model + without any conversion. the following variables do not need to be set. + if FALSE, the buf data is converted into an input consistent with the model + according to the following type and fmt. so the following variables + need to be set.*/ + uint32_t h_stride; /* the stride along the height dimention of input, + Note: it is write-only, if it was set to 0, h_stride = height. */ +} rknn_tensor_attr; + +typedef struct _rknn_input_range { + uint32_t index; /* input parameter, the index of input/output tensor, + need set before call rknn_query. */ + uint32_t shape_number; /* the number of shape. */ + rknn_tensor_format fmt; /* the data format of tensor. */ + char name[RKNN_MAX_NAME_LEN]; /* the name of tensor. */ + uint32_t dyn_range[RKNN_MAX_DYNAMIC_SHAPE_NUM][RKNN_MAX_DIMS]; /* the dynamic input dimensions range. */ + uint32_t n_dims; /* the number of dimensions. */ + +} rknn_input_range; + +/* + the information for RKNN_QUERY_PERF_DETAIL. +*/ +typedef struct _rknn_perf_detail { + char* perf_data; /* the string pointer of perf detail. don't need free it by user. */ + uint64_t data_len; /* the string length. */ +} rknn_perf_detail; + +/* + the information for RKNN_QUERY_PERF_RUN. +*/ +typedef struct _rknn_perf_run { + int64_t run_duration; /* real inference time (us) */ +} rknn_perf_run; + +/* + the information for RKNN_QUERY_SDK_VERSION. +*/ +typedef struct _rknn_sdk_version { + char api_version[256]; /* the version of rknn api. */ + char drv_version[256]; /* the version of rknn driver. */ +} rknn_sdk_version; + +/* + the information for RKNN_QUERY_MEM_SIZE. +*/ +typedef struct _rknn_mem_size { + uint32_t total_weight_size; /* the weight memory size */ + uint32_t total_internal_size; /* the internal memory size, exclude inputs/outputs */ + uint64_t total_dma_allocated_size; /* total dma memory allocated size */ + uint32_t total_sram_size; /* total system sram size reserved for rknn */ + uint32_t free_sram_size; /* free system sram size reserved for rknn */ + uint32_t reserved[10]; /* reserved */ +} rknn_mem_size; + +/* + the information for RKNN_QUERY_CUSTOM_STRING. +*/ +typedef struct _rknn_custom_string { + char string[1024]; /* the string of custom, lengths max to 1024 bytes */ +} rknn_custom_string; + +/* + The flags of rknn_tensor_mem. +*/ +typedef enum _rknn_tensor_mem_flags { + RKNN_TENSOR_MEMORY_FLAGS_ALLOC_INSIDE = 1, /*Used to mark in rknn_destroy_mem() whether it is necessary to release the "mem" pointer itself. + If the flag RKNN_TENSOR_MEMORY_FLAGS_ALLOC_INSIDE is set, rknn_destroy_mem() will call free(mem).*/ + RKNN_TENSOR_MEMORY_FLAGS_FROM_FD = 2, /*Used to mark in rknn_create_mem_from_fd() whether it is necessary to release the "mem" pointer itself. + If the flag RKNN_TENSOR_MEMORY_FLAGS_FROM_FD is set, rknn_destroy_mem() will call free(mem).*/ + RKNN_TENSOR_MEMORY_FLAGS_FROM_PHYS = 3, /*Used to mark in rknn_create_mem_from_phys() whether it is necessary to release the "mem" pointer itself. + If the flag RKNN_TENSOR_MEMORY_FLAGS_FROM_PHYS is set, rknn_destroy_mem() will call free(mem).*/ + RKNN_TENSOR_MEMORY_FLAGS_UNKNOWN +} rknn_tensor_mem_flags; + +/* + The mode to allocate rknn memory. +*/ +typedef enum _rknn_mem_alloc_flags { + RKNN_FLAG_MEMORY_FLAGS_DEFAULT = 0 << 0, /* Same with RKNN_FLAG_MEMORY_CACHEABLE */ + RKNN_FLAG_MEMORY_CACHEABLE = 1 << 0, /* Create Cacheable memory. */ + RKNN_FLAG_MEMORY_NON_CACHEABLE = 1 << 1, /* Create NON-Cacheable memory. */ + RKNN_FLAG_MEMORY_TRY_ALLOC_SRAM = 1 << 2, /* Try to allocate memory in SRAM if possible. if SRAM is not enough, allocate rest memory in DRAM. */ +} rknn_mem_alloc_flags; + +/* + The mode to sync cacheable rknn memory. +*/ +typedef enum _rknn_mem_sync_mode { + RKNN_MEMORY_SYNC_TO_DEVICE = 0x1, /* the mode used for consistency of device access after CPU accesses data. */ + RKNN_MEMORY_SYNC_FROM_DEVICE = 0x2, /* the mode used for consistency of CPU access after device accesses data. */ + RKNN_MEMORY_SYNC_BIDIRECTIONAL = RKNN_MEMORY_SYNC_TO_DEVICE | RKNN_MEMORY_SYNC_FROM_DEVICE, /* the mode used for consistency of data access + between device and CPU in both directions. */ +} rknn_mem_sync_mode; + +/* + the memory information of tensor. +*/ +typedef struct _rknn_tensor_memory { + void* virt_addr; /* the virtual address of tensor buffer. */ + uint64_t phys_addr; /* the physical address of tensor buffer. */ + int32_t fd; /* the fd of tensor buffer. */ + int32_t offset; /* indicates the offset of the memory. */ + uint32_t size; /* the size of tensor buffer. */ + uint32_t flags; /* the flags of tensor buffer, reserved */ + void * priv_data; /* the private data of tensor buffer. */ +} rknn_tensor_mem; + +/* + the input information for rknn_input_set. +*/ +typedef struct _rknn_input { + uint32_t index; /* the input index. */ + void* buf; /* the input buf for index. */ + uint32_t size; /* the size of input buf. */ + uint8_t pass_through; /* pass through mode. + if TRUE, the buf data is passed directly to the input node of the rknn model + without any conversion. the following variables do not need to be set. + if FALSE, the buf data is converted into an input consistent with the model + according to the following type and fmt. so the following variables + need to be set.*/ + rknn_tensor_type type; /* the data type of input buf. */ + rknn_tensor_format fmt; /* the data format of input buf. + currently the internal input format of NPU is NCHW by default. + so entering NCHW data can avoid the format conversion in the driver. */ +} rknn_input; + +/* + the output information for rknn_outputs_get. +*/ +typedef struct _rknn_output { + uint8_t want_float; /* want transfer output data to float */ + uint8_t is_prealloc; /* whether buf is pre-allocated. + if TRUE, the following variables need to be set. + if FALSE, the following variables do not need to be set. */ + uint32_t index; /* the output index. */ + void* buf; /* the output buf for index. + when is_prealloc = FALSE and rknn_outputs_release called, + this buf pointer will be free and don't use it anymore. */ + uint32_t size; /* the size of output buf. */ +} rknn_output; + +/* + the extend information for rknn_init. +*/ +typedef struct _rknn_init_extend { + rknn_context ctx; /* rknn context */ + int32_t real_model_offset; /* real rknn model file offset, only valid when init context with rknn file path and zero-copy model model */ + uint32_t real_model_size; /* real rknn model file size, only valid when init context with rknn file path and zero-copy model model */ + int32_t model_buffer_fd; /* the fd of model buffer. */ + uint32_t model_buffer_flags; /* the flags of model_buffer */ + uint8_t reserved[112]; /* reserved */ +} rknn_init_extend; + +/* + the extend information for rknn_run. +*/ +typedef struct _rknn_run_extend { + uint64_t frame_id; /* output parameter, indicate current frame id of run. */ + int32_t non_block; /* block flag of run, 0 is block else 1 is non block */ + int32_t timeout_ms; /* timeout for block mode, in milliseconds */ + int32_t fence_fd; /* fence fd from other unit */ +} rknn_run_extend; + +/* + the extend information for rknn_outputs_get. +*/ +typedef struct _rknn_output_extend { + uint64_t frame_id; /* output parameter, indicate the frame id of outputs, corresponds to + struct rknn_run_extend.frame_id.*/ +} rknn_output_extend; + + +/* rknn_init + + initial the context and load the rknn model. + + input: + rknn_context* context the pointer of context handle. + void* model if size > 0, pointer to the rknn model, if size = 0, filepath to the rknn model. + uint32_t size the size of rknn model. + uint32_t flag extend flag, see the define of RKNN_FLAG_XXX_XXX. + rknn_init_extend* extend the extend information of init. + return: + int error code. +*/ +int rknn_init(rknn_context* context, void* model, uint32_t size, uint32_t flag, rknn_init_extend* extend); + +/* rknn_dup_context + + initial the context and load the rknn model. + + input: + rknn_context* context_in the pointer of context in handle. + rknn_context* context_out the pointer of context out handle. + return: + int error code. +*/ +int rknn_dup_context(rknn_context* context_in, rknn_context* context_out); + +/* rknn_destroy + + unload the rknn model and destroy the context. + + input: + rknn_context context the handle of context. + return: + int error code. +*/ +int rknn_destroy(rknn_context context); + + +/* rknn_query + + query the information about model or others. see rknn_query_cmd. + + input: + rknn_context context the handle of context. + rknn_query_cmd cmd the command of query. + void* info the buffer point of information. + uint32_t size the size of information. + return: + int error code. +*/ +int rknn_query(rknn_context context, rknn_query_cmd cmd, void* info, uint32_t size); + + +/* rknn_inputs_set + + set inputs information by input index of rknn model. + inputs information see rknn_input. + + input: + rknn_context context the handle of context. + uint32_t n_inputs the number of inputs. + rknn_input inputs[] the arrays of inputs information, see rknn_input. + return: + int error code +*/ +int rknn_inputs_set(rknn_context context, uint32_t n_inputs, rknn_input inputs[]); + +/* + rknn_set_batch_core_num + + set rknn batch core_num. + + input: + rknn_context context the handle of context. + int core_num the core number. + return: + int error code. + +*/ +int rknn_set_batch_core_num(rknn_context context, int core_num); + +/* rknn_set_core_mask + + set the core mask for the model.(only supported on multi-core NPU platform) + + RKNN_NPU_CORE_AUTO: auto mode, default value + RKNN_NPU_CORE_0: core 0 mode + RKNN_NPU_CORE_1: core 1 mode + RKNN_NPU_CORE_2: core 2 mode + RKNN_NPU_CORE_0_1: combine core 0/1 mode + RKNN_NPU_CORE_0_1_2: combine core 0/1/2 mode + RKNN_NPU_CORE_ALL: auto mode, select multiple npu cores to run depending on platform + + + input: + rknn_context context the handle of context. + rknn_core_mask core_mask the core mask. + return: + int error code. +*/ +int rknn_set_core_mask(rknn_context context, rknn_core_mask core_mask); + +/* rknn_run + + run the model to execute inference. + + input: + rknn_context context the handle of context. + rknn_run_extend* extend the extend information of run. + return: + int error code. +*/ +int rknn_run(rknn_context context, rknn_run_extend* extend); + + +/* rknn_wait + + wait the model after execute inference. + + input: + rknn_context context the handle of context. + rknn_run_extend* extend the extend information of run. + return: + int error code. +*/ +int rknn_wait(rknn_context context, rknn_run_extend* extend); + + +/* rknn_outputs_get + + wait the inference to finish and get the outputs. + this function will block until inference finish. + the results will set to outputs[]. + + input: + rknn_context context the handle of context. + uint32_t n_outputs the number of outputs. + rknn_output outputs[] the arrays of output, see rknn_output. + rknn_output_extend* the extend information of output. + return: + int error code. +*/ +int rknn_outputs_get(rknn_context context, uint32_t n_outputs, rknn_output outputs[], rknn_output_extend* extend); + + +/* rknn_outputs_release + + release the outputs that get by rknn_outputs_get. + after called, the rknn_output[x].buf get from rknn_outputs_get will + also be free when rknn_output[x].is_prealloc = FALSE. + + input: + rknn_context context the handle of context. + uint32_t n_ouputs the number of outputs. + rknn_output outputs[] the arrays of output. + return: + int error code +*/ +int rknn_outputs_release(rknn_context context, uint32_t n_ouputs, rknn_output outputs[]); + + +/* new api for zero copy */ + +/* rknn_create_mem_from_phys (memory allocated outside) + + initialize tensor memory from physical address. + + input: + rknn_context ctx the handle of context. + uint64_t phys_addr physical address. + void *virt_addr virtual address. + uint32_t size the size of tensor buffer. + return: + rknn_tensor_mem the pointer of tensor memory information. +*/ +rknn_tensor_mem* rknn_create_mem_from_phys(rknn_context ctx, uint64_t phys_addr, void *virt_addr, uint32_t size); + + +/* rknn_create_mem_from_fd (memory allocated outside) + + initialize tensor memory from file description. + + input: + rknn_context ctx the handle of context. + int32_t fd file description. + void *virt_addr virtual address. + uint32_t size the size of tensor buffer. + int32_t offset indicates the offset of the memory (virt_addr without offset). + return: + rknn_tensor_mem the pointer of tensor memory information. +*/ +rknn_tensor_mem* rknn_create_mem_from_fd(rknn_context ctx, int32_t fd, void *virt_addr, uint32_t size, int32_t offset); + + +/* rknn_create_mem_from_mb_blk (memory allocated outside) + + create tensor memory from mb_blk. + + input: + rknn_context ctx the handle of context. + void *mb_blk mb_blk allocate from system api. + int32_t offset indicates the offset of the memory. + return: + rknn_tensor_mem the pointer of tensor memory information. +*/ +rknn_tensor_mem* rknn_create_mem_from_mb_blk(rknn_context ctx, void *mb_blk, int32_t offset); + + +/* rknn_create_mem (memory allocated inside) + + create tensor memory. + + input: + rknn_context ctx the handle of context. + uint32_t size the size of tensor buffer. + return: + rknn_tensor_mem the pointer of tensor memory information. +*/ +rknn_tensor_mem* rknn_create_mem(rknn_context ctx, uint32_t size); + +/* rknn_create_mem2 (memory allocated inside) + + create tensor memory. + + input: + rknn_context ctx the handle of context. + uint64_t size the size of tensor buffer. + uint64_t alloc_flags memory allocation flags. + return: + rknn_tensor_mem the pointer of tensor memory information. +*/ +rknn_tensor_mem* rknn_create_mem2(rknn_context ctx, uint64_t size, uint64_t alloc_flags); + +/* rknn_destroy_mem (support allocate inside and outside) + + destroy tensor memory. + + input: + rknn_context ctx the handle of context. + rknn_tensor_mem *mem the pointer of tensor memory information. + return: + int error code +*/ +int rknn_destroy_mem(rknn_context ctx, rknn_tensor_mem *mem); + + +/* rknn_set_weight_mem + + set the weight memory. + + input: + rknn_context ctx the handle of context. + rknn_tensor_mem *mem the array of tensor memory information + return: + int error code. +*/ +int rknn_set_weight_mem(rknn_context ctx, rknn_tensor_mem *mem); + + +/* rknn_set_internal_mem + + set the internal memory. + + input: + rknn_context ctx the handle of context. + rknn_tensor_mem *mem the array of tensor memory information + return: + int error code. +*/ +int rknn_set_internal_mem(rknn_context ctx, rknn_tensor_mem *mem); + + +/* rknn_set_io_mem + + set the input and output tensors buffer. + + input: + rknn_context ctx the handle of context. + rknn_tensor_mem *mem the array of tensor memory information. + rknn_tensor_attr *attr the attribute of input or output tensor buffer. + return: + int error code. +*/ +int rknn_set_io_mem(rknn_context ctx, rknn_tensor_mem *mem, rknn_tensor_attr *attr); + +/* rknn_set_input_shape(deprecated) + + set the input tensor shape (only valid for dynamic shape rknn model). + + input: + rknn_context ctx the handle of context. + rknn_tensor_attr *attr the attribute of input or output tensor buffer. + return: + int error code. +*/ +int rknn_set_input_shape(rknn_context ctx, rknn_tensor_attr* attr); + +/* rknn_set_input_shapes + + set all the input tensor shapes. graph will run under current set of input shapes after rknn_set_input_shapes.(only valid for dynamic shape rknn model). + + input: + rknn_context ctx the handle of context. + uint32_t n_inputs the number of inputs. + rknn_tensor_attr attr[] the attribute array of all input tensors. + return: + int error code. +*/ +int rknn_set_input_shapes(rknn_context ctx, uint32_t n_inputs, rknn_tensor_attr attr[]); + +/* rknn_mem_sync + + sync cacheable rknn memory when both cpu and device access data. + + input: + rknn_context context the handle of context. + rknn_tensor_mem *mem the pointer of tensor memory information. + rknn_mem_sync_mode mode the mode of sync cache. + return: + int error code. +*/ +int rknn_mem_sync(rknn_context context, rknn_tensor_mem* mem, rknn_mem_sync_mode mode); + +#ifdef __cplusplus +} //extern "C" +#endif + +#endif //_RKNN_API_H diff --git a/docs/face-knn-source/include/stb_image.h b/docs/face-knn-source/include/stb_image.h new file mode 100644 index 0000000..accef48 --- /dev/null +++ b/docs/face-knn-source/include/stb_image.h @@ -0,0 +1,7762 @@ +/* stb_image - v2.26 - public domain image loader - http://nothings.org/stb + no warranty implied; use at your own risk + + Do this: + #define STB_IMAGE_IMPLEMENTATION + before you include this file in *one* C or C++ file to create the implementation. + + // i.e. it should look like this: + #include ... + #include ... + #include ... + #define STB_IMAGE_IMPLEMENTATION + #include "stb_image.h" + + You can #define STBI_ASSERT(x) before the #include to avoid using assert.h. + And #define STBI_MALLOC, STBI_REALLOC, and STBI_FREE to avoid using malloc,realloc,free + + + QUICK NOTES: + Primarily of interest to game developers and other people who can + avoid problematic images and only need the trivial interface + + JPEG baseline & progressive (12 bpc/arithmetic not supported, same as stock IJG lib) + PNG 1/2/4/8/16-bit-per-channel + + TGA (not sure what subset, if a subset) + BMP non-1bpp, non-RLE + PSD (composited view only, no extra channels, 8/16 bit-per-channel) + + GIF (*comp always reports as 4-channel) + HDR (radiance rgbE format) + PIC (Softimage PIC) + PNM (PPM and PGM binary only) + + Animated GIF still needs a proper API, but here's one way to do it: + http://gist.github.com/urraka/685d9a6340b26b830d49 + + - decode from memory or through FILE (define STBI_NO_STDIO to remove code) + - decode from arbitrary I/O callbacks + - SIMD acceleration on x86/x64 (SSE2) and ARM (NEON) + + Full documentation under "DOCUMENTATION" below. + + +LICENSE + + See end of file for license information. + +RECENT REVISION HISTORY: + + 2.26 (2020-07-13) many minor fixes + 2.25 (2020-02-02) fix warnings + 2.24 (2020-02-02) fix warnings; thread-local failure_reason and flip_vertically + 2.23 (2019-08-11) fix clang static analysis warning + 2.22 (2019-03-04) gif fixes, fix warnings + 2.21 (2019-02-25) fix typo in comment + 2.20 (2019-02-07) support utf8 filenames in Windows; fix warnings and platform ifdefs + 2.19 (2018-02-11) fix warning + 2.18 (2018-01-30) fix warnings + 2.17 (2018-01-29) bugfix, 1-bit BMP, 16-bitness query, fix warnings + 2.16 (2017-07-23) all functions have 16-bit variants; optimizations; bugfixes + 2.15 (2017-03-18) fix png-1,2,4; all Imagenet JPGs; no runtime SSE detection on GCC + 2.14 (2017-03-03) remove deprecated STBI_JPEG_OLD; fixes for Imagenet JPGs + 2.13 (2016-12-04) experimental 16-bit API, only for PNG so far; fixes + 2.12 (2016-04-02) fix typo in 2.11 PSD fix that caused crashes + 2.11 (2016-04-02) 16-bit PNGS; enable SSE2 in non-gcc x64 + RGB-format JPEG; remove white matting in PSD; + allocate large structures on the stack; + correct channel count for PNG & BMP + 2.10 (2016-01-22) avoid warning introduced in 2.09 + 2.09 (2016-01-16) 16-bit TGA; comments in PNM files; STBI_REALLOC_SIZED + + See end of file for full revision history. + + + ============================ Contributors ========================= + + Image formats Extensions, features + Sean Barrett (jpeg, png, bmp) Jetro Lauha (stbi_info) + Nicolas Schulz (hdr, psd) Martin "SpartanJ" Golini (stbi_info) + Jonathan Dummer (tga) James "moose2000" Brown (iPhone PNG) + Jean-Marc Lienher (gif) Ben "Disch" Wenger (io callbacks) + Tom Seddon (pic) Omar Cornut (1/2/4-bit PNG) + Thatcher Ulrich (psd) Nicolas Guillemot (vertical flip) + Ken Miller (pgm, ppm) Richard Mitton (16-bit PSD) + github:urraka (animated gif) Junggon Kim (PNM comments) + Christopher Forseth (animated gif) Daniel Gibson (16-bit TGA) + socks-the-fox (16-bit PNG) + Jeremy Sawicki (handle all ImageNet JPGs) + Optimizations & bugfixes Mikhail Morozov (1-bit BMP) + Fabian "ryg" Giesen Anael Seghezzi (is-16-bit query) + Arseny Kapoulkine + John-Mark Allen + Carmelo J Fdez-Aguera + + Bug & warning fixes + Marc LeBlanc David Woo Guillaume George Martins Mozeiko + Christpher Lloyd Jerry Jansson Joseph Thomson Blazej Dariusz Roszkowski + Phil Jordan Dave Moore Roy Eltham + Hayaki Saito Nathan Reed Won Chun + Luke Graham Johan Duparc Nick Verigakis the Horde3D community + Thomas Ruf Ronny Chevalier github:rlyeh + Janez Zemva John Bartholomew Michal Cichon github:romigrou + Jonathan Blow Ken Hamada Tero Hanninen github:svdijk + Laurent Gomila Cort Stratton github:snagar + Aruelien Pocheville Sergio Gonzalez Thibault Reuille github:Zelex + Cass Everitt Ryamond Barbiero github:grim210 + Paul Du Bois Engin Manap Aldo Culquicondor github:sammyhw + Philipp Wiesemann Dale Weiler Oriol Ferrer Mesia github:phprus + Josh Tobin Matthew Gregan github:poppolopoppo + Julian Raschke Gregory Mullen Christian Floisand github:darealshinji + Baldur Karlsson Kevin Schmidt JR Smith github:Michaelangel007 + Brad Weinberger Matvey Cherevko [reserved] + Luca Sas Alexander Veselov Zack Middleton [reserved] + Ryan C. Gordon [reserved] [reserved] + DO NOT ADD YOUR NAME HERE + + To add your name to the credits, pick a random blank space in the middle and fill it. + 80% of merge conflicts on stb PRs are due to people adding their name at the end + of the credits. +*/ + +#ifndef STBI_INCLUDE_STB_IMAGE_H +#define STBI_INCLUDE_STB_IMAGE_H + +// DOCUMENTATION +// +// Limitations: +// - no 12-bit-per-channel JPEG +// - no JPEGs with arithmetic coding +// - GIF always returns *comp=4 +// +// Basic usage (see HDR discussion below for HDR usage): +// int x,y,n; +// unsigned char *data = stbi_load(filename, &x, &y, &n, 0); +// // ... process data if not NULL ... +// // ... x = width, y = height, n = # 8-bit components per pixel ... +// // ... replace '0' with '1'..'4' to force that many components per pixel +// // ... but 'n' will always be the number that it would have been if you said 0 +// stbi_image_free(data) +// +// Standard parameters: +// int *x -- outputs image width in pixels +// int *y -- outputs image height in pixels +// int *channels_in_file -- outputs # of image components in image file +// int desired_channels -- if non-zero, # of image components requested in result +// +// The return value from an image loader is an 'unsigned char *' which points +// to the pixel data, or NULL on an allocation failure or if the image is +// corrupt or invalid. The pixel data consists of *y scanlines of *x pixels, +// with each pixel consisting of N interleaved 8-bit components; the first +// pixel pointed to is top-left-most in the image. There is no padding between +// image scanlines or between pixels, regardless of format. The number of +// components N is 'desired_channels' if desired_channels is non-zero, or +// *channels_in_file otherwise. If desired_channels is non-zero, +// *channels_in_file has the number of components that _would_ have been +// output otherwise. E.g. if you set desired_channels to 4, you will always +// get RGBA output, but you can check *channels_in_file to see if it's trivially +// opaque because e.g. there were only 3 channels in the source image. +// +// An output image with N components has the following components interleaved +// in this order in each pixel: +// +// N=#comp components +// 1 grey +// 2 grey, alpha +// 3 red, green, blue +// 4 red, green, blue, alpha +// +// If image loading fails for any reason, the return value will be NULL, +// and *x, *y, *channels_in_file will be unchanged. The function +// stbi_failure_reason() can be queried for an extremely brief, end-user +// unfriendly explanation of why the load failed. Define STBI_NO_FAILURE_STRINGS +// to avoid compiling these strings at all, and STBI_FAILURE_USERMSG to get slightly +// more user-friendly ones. +// +// Paletted PNG, BMP, GIF, and PIC images are automatically depalettized. +// +// =========================================================================== +// +// UNICODE: +// +// If compiling for Windows and you wish to use Unicode filenames, compile +// with +// #define STBI_WINDOWS_UTF8 +// and pass utf8-encoded filenames. Call stbi_convert_wchar_to_utf8 to convert +// Windows wchar_t filenames to utf8. +// +// =========================================================================== +// +// Philosophy +// +// stb libraries are designed with the following priorities: +// +// 1. easy to use +// 2. easy to maintain +// 3. good performance +// +// Sometimes I let "good performance" creep up in priority over "easy to maintain", +// and for best performance I may provide less-easy-to-use APIs that give higher +// performance, in addition to the easy-to-use ones. Nevertheless, it's important +// to keep in mind that from the standpoint of you, a client of this library, +// all you care about is #1 and #3, and stb libraries DO NOT emphasize #3 above all. +// +// Some secondary priorities arise directly from the first two, some of which +// provide more explicit reasons why performance can't be emphasized. +// +// - Portable ("ease of use") +// - Small source code footprint ("easy to maintain") +// - No dependencies ("ease of use") +// +// =========================================================================== +// +// I/O callbacks +// +// I/O callbacks allow you to read from arbitrary sources, like packaged +// files or some other source. Data read from callbacks are processed +// through a small internal buffer (currently 128 bytes) to try to reduce +// overhead. +// +// The three functions you must define are "read" (reads some bytes of data), +// "skip" (skips some bytes of data), "eof" (reports if the stream is at the end). +// +// =========================================================================== +// +// SIMD support +// +// The JPEG decoder will try to automatically use SIMD kernels on x86 when +// supported by the compiler. For ARM Neon support, you must explicitly +// request it. +// +// (The old do-it-yourself SIMD API is no longer supported in the current +// code.) +// +// On x86, SSE2 will automatically be used when available based on a run-time +// test; if not, the generic C versions are used as a fall-back. On ARM targets, +// the typical path is to have separate builds for NEON and non-NEON devices +// (at least this is true for iOS and Android). Therefore, the NEON support is +// toggled by a build flag: define STBI_NEON to get NEON loops. +// +// If for some reason you do not want to use any of SIMD code, or if +// you have issues compiling it, you can disable it entirely by +// defining STBI_NO_SIMD. +// +// =========================================================================== +// +// HDR image support (disable by defining STBI_NO_HDR) +// +// stb_image supports loading HDR images in general, and currently the Radiance +// .HDR file format specifically. You can still load any file through the existing +// interface; if you attempt to load an HDR file, it will be automatically remapped +// to LDR, assuming gamma 2.2 and an arbitrary scale factor defaulting to 1; +// both of these constants can be reconfigured through this interface: +// +// stbi_hdr_to_ldr_gamma(2.2f); +// stbi_hdr_to_ldr_scale(1.0f); +// +// (note, do not use _inverse_ constants; stbi_image will invert them +// appropriately). +// +// Additionally, there is a new, parallel interface for loading files as +// (linear) floats to preserve the full dynamic range: +// +// float *data = stbi_loadf(filename, &x, &y, &n, 0); +// +// If you load LDR images through this interface, those images will +// be promoted to floating point values, run through the inverse of +// constants corresponding to the above: +// +// stbi_ldr_to_hdr_scale(1.0f); +// stbi_ldr_to_hdr_gamma(2.2f); +// +// Finally, given a filename (or an open file or memory block--see header +// file for details) containing image data, you can query for the "most +// appropriate" interface to use (that is, whether the image is HDR or +// not), using: +// +// stbi_is_hdr(char *filename); +// +// =========================================================================== +// +// iPhone PNG support: +// +// By default we convert iphone-formatted PNGs back to RGB, even though +// they are internally encoded differently. You can disable this conversion +// by calling stbi_convert_iphone_png_to_rgb(0), in which case +// you will always just get the native iphone "format" through (which +// is BGR stored in RGB). +// +// Call stbi_set_unpremultiply_on_load(1) as well to force a divide per +// pixel to remove any premultiplied alpha *only* if the image file explicitly +// says there's premultiplied data (currently only happens in iPhone images, +// and only if iPhone convert-to-rgb processing is on). +// +// =========================================================================== +// +// ADDITIONAL CONFIGURATION +// +// - You can suppress implementation of any of the decoders to reduce +// your code footprint by #defining one or more of the following +// symbols before creating the implementation. +// +// STBI_NO_JPEG +// STBI_NO_PNG +// STBI_NO_BMP +// STBI_NO_PSD +// STBI_NO_TGA +// STBI_NO_GIF +// STBI_NO_HDR +// STBI_NO_PIC +// STBI_NO_PNM (.ppm and .pgm) +// +// - You can request *only* certain decoders and suppress all other ones +// (this will be more forward-compatible, as addition of new decoders +// doesn't require you to disable them explicitly): +// +// STBI_ONLY_JPEG +// STBI_ONLY_PNG +// STBI_ONLY_BMP +// STBI_ONLY_PSD +// STBI_ONLY_TGA +// STBI_ONLY_GIF +// STBI_ONLY_HDR +// STBI_ONLY_PIC +// STBI_ONLY_PNM (.ppm and .pgm) +// +// - If you use STBI_NO_PNG (or _ONLY_ without PNG), and you still +// want the zlib decoder to be available, #define STBI_SUPPORT_ZLIB +// +// - If you define STBI_MAX_DIMENSIONS, stb_image will reject images greater +// than that size (in either width or height) without further processing. +// This is to let programs in the wild set an upper bound to prevent +// denial-of-service attacks on untrusted data, as one could generate a +// valid image of gigantic dimensions and force stb_image to allocate a +// huge block of memory and spend disproportionate time decoding it. By +// default this is set to (1 << 24), which is 16777216, but that's still +// very big. + +#ifndef STBI_NO_STDIO +#include +#endif // STBI_NO_STDIO + +#define STBI_VERSION 1 + +enum +{ + STBI_default = 0, // only used for desired_channels + + STBI_grey = 1, + STBI_grey_alpha = 2, + STBI_rgb = 3, + STBI_rgb_alpha = 4 +}; + +#include +typedef unsigned char stbi_uc; +typedef unsigned short stbi_us; + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef STBIDEF +#ifdef STB_IMAGE_STATIC +#define STBIDEF static +#else +#define STBIDEF extern +#endif +#endif + +////////////////////////////////////////////////////////////////////////////// +// +// PRIMARY API - works on images of any type +// + +// +// load image by filename, open file, or memory buffer +// + +typedef struct +{ + int (*read) (void *user,char *data,int size); // fill 'data' with 'size' bytes. return number of bytes actually read + void (*skip) (void *user,int n); // skip the next 'n' bytes, or 'unget' the last -n bytes if negative + int (*eof) (void *user); // returns nonzero if we are at end of file/data +} stbi_io_callbacks; + +//////////////////////////////////// +// +// 8-bits-per-channel interface +// + +STBIDEF stbi_uc *stbi_load_from_memory (stbi_uc const *buffer, int len , int *x, int *y, int *channels_in_file, int desired_channels); +STBIDEF stbi_uc *stbi_load_from_callbacks(stbi_io_callbacks const *clbk , void *user, int *x, int *y, int *channels_in_file, int desired_channels); + +#ifndef STBI_NO_STDIO +STBIDEF stbi_uc *stbi_load (char const *filename, int *x, int *y, int *channels_in_file, int desired_channels); +STBIDEF stbi_uc *stbi_load_from_file (FILE *f, int *x, int *y, int *channels_in_file, int desired_channels); +// for stbi_load_from_file, file pointer is left pointing immediately after image +#endif + +#ifndef STBI_NO_GIF +STBIDEF stbi_uc *stbi_load_gif_from_memory(stbi_uc const *buffer, int len, int **delays, int *x, int *y, int *z, int *comp, int req_comp); +#endif + +#ifdef STBI_WINDOWS_UTF8 +STBIDEF int stbi_convert_wchar_to_utf8(char *buffer, size_t bufferlen, const wchar_t* input); +#endif + +//////////////////////////////////// +// +// 16-bits-per-channel interface +// + +STBIDEF stbi_us *stbi_load_16_from_memory (stbi_uc const *buffer, int len, int *x, int *y, int *channels_in_file, int desired_channels); +STBIDEF stbi_us *stbi_load_16_from_callbacks(stbi_io_callbacks const *clbk, void *user, int *x, int *y, int *channels_in_file, int desired_channels); + +#ifndef STBI_NO_STDIO +STBIDEF stbi_us *stbi_load_16 (char const *filename, int *x, int *y, int *channels_in_file, int desired_channels); +STBIDEF stbi_us *stbi_load_from_file_16(FILE *f, int *x, int *y, int *channels_in_file, int desired_channels); +#endif + +//////////////////////////////////// +// +// float-per-channel interface +// +#ifndef STBI_NO_LINEAR + STBIDEF float *stbi_loadf_from_memory (stbi_uc const *buffer, int len, int *x, int *y, int *channels_in_file, int desired_channels); + STBIDEF float *stbi_loadf_from_callbacks (stbi_io_callbacks const *clbk, void *user, int *x, int *y, int *channels_in_file, int desired_channels); + + #ifndef STBI_NO_STDIO + STBIDEF float *stbi_loadf (char const *filename, int *x, int *y, int *channels_in_file, int desired_channels); + STBIDEF float *stbi_loadf_from_file (FILE *f, int *x, int *y, int *channels_in_file, int desired_channels); + #endif +#endif + +#ifndef STBI_NO_HDR + STBIDEF void stbi_hdr_to_ldr_gamma(float gamma); + STBIDEF void stbi_hdr_to_ldr_scale(float scale); +#endif // STBI_NO_HDR + +#ifndef STBI_NO_LINEAR + STBIDEF void stbi_ldr_to_hdr_gamma(float gamma); + STBIDEF void stbi_ldr_to_hdr_scale(float scale); +#endif // STBI_NO_LINEAR + +// stbi_is_hdr is always defined, but always returns false if STBI_NO_HDR +STBIDEF int stbi_is_hdr_from_callbacks(stbi_io_callbacks const *clbk, void *user); +STBIDEF int stbi_is_hdr_from_memory(stbi_uc const *buffer, int len); +#ifndef STBI_NO_STDIO +STBIDEF int stbi_is_hdr (char const *filename); +STBIDEF int stbi_is_hdr_from_file(FILE *f); +#endif // STBI_NO_STDIO + + +// get a VERY brief reason for failure +// on most compilers (and ALL modern mainstream compilers) this is threadsafe +STBIDEF const char *stbi_failure_reason (void); + +// free the loaded image -- this is just free() +STBIDEF void stbi_image_free (void *retval_from_stbi_load); + +// get image dimensions & components without fully decoding +STBIDEF int stbi_info_from_memory(stbi_uc const *buffer, int len, int *x, int *y, int *comp); +STBIDEF int stbi_info_from_callbacks(stbi_io_callbacks const *clbk, void *user, int *x, int *y, int *comp); +STBIDEF int stbi_is_16_bit_from_memory(stbi_uc const *buffer, int len); +STBIDEF int stbi_is_16_bit_from_callbacks(stbi_io_callbacks const *clbk, void *user); + +#ifndef STBI_NO_STDIO +STBIDEF int stbi_info (char const *filename, int *x, int *y, int *comp); +STBIDEF int stbi_info_from_file (FILE *f, int *x, int *y, int *comp); +STBIDEF int stbi_is_16_bit (char const *filename); +STBIDEF int stbi_is_16_bit_from_file(FILE *f); +#endif + + + +// for image formats that explicitly notate that they have premultiplied alpha, +// we just return the colors as stored in the file. set this flag to force +// unpremultiplication. results are undefined if the unpremultiply overflow. +STBIDEF void stbi_set_unpremultiply_on_load(int flag_true_if_should_unpremultiply); + +// indicate whether we should process iphone images back to canonical format, +// or just pass them through "as-is" +STBIDEF void stbi_convert_iphone_png_to_rgb(int flag_true_if_should_convert); + +// flip the image vertically, so the first pixel in the output array is the bottom left +STBIDEF void stbi_set_flip_vertically_on_load(int flag_true_if_should_flip); + +// as above, but only applies to images loaded on the thread that calls the function +// this function is only available if your compiler supports thread-local variables; +// calling it will fail to link if your compiler doesn't +STBIDEF void stbi_set_flip_vertically_on_load_thread(int flag_true_if_should_flip); + +// ZLIB client - used by PNG, available for other purposes + +STBIDEF char *stbi_zlib_decode_malloc_guesssize(const char *buffer, int len, int initial_size, int *outlen); +STBIDEF char *stbi_zlib_decode_malloc_guesssize_headerflag(const char *buffer, int len, int initial_size, int *outlen, int parse_header); +STBIDEF char *stbi_zlib_decode_malloc(const char *buffer, int len, int *outlen); +STBIDEF int stbi_zlib_decode_buffer(char *obuffer, int olen, const char *ibuffer, int ilen); + +STBIDEF char *stbi_zlib_decode_noheader_malloc(const char *buffer, int len, int *outlen); +STBIDEF int stbi_zlib_decode_noheader_buffer(char *obuffer, int olen, const char *ibuffer, int ilen); + + +#ifdef __cplusplus +} +#endif + +// +// +//// end header file ///////////////////////////////////////////////////// +#endif // STBI_INCLUDE_STB_IMAGE_H + +#ifdef STB_IMAGE_IMPLEMENTATION + +#if defined(STBI_ONLY_JPEG) || defined(STBI_ONLY_PNG) || defined(STBI_ONLY_BMP) \ + || defined(STBI_ONLY_TGA) || defined(STBI_ONLY_GIF) || defined(STBI_ONLY_PSD) \ + || defined(STBI_ONLY_HDR) || defined(STBI_ONLY_PIC) || defined(STBI_ONLY_PNM) \ + || defined(STBI_ONLY_ZLIB) + #ifndef STBI_ONLY_JPEG + #define STBI_NO_JPEG + #endif + #ifndef STBI_ONLY_PNG + #define STBI_NO_PNG + #endif + #ifndef STBI_ONLY_BMP + #define STBI_NO_BMP + #endif + #ifndef STBI_ONLY_PSD + #define STBI_NO_PSD + #endif + #ifndef STBI_ONLY_TGA + #define STBI_NO_TGA + #endif + #ifndef STBI_ONLY_GIF + #define STBI_NO_GIF + #endif + #ifndef STBI_ONLY_HDR + #define STBI_NO_HDR + #endif + #ifndef STBI_ONLY_PIC + #define STBI_NO_PIC + #endif + #ifndef STBI_ONLY_PNM + #define STBI_NO_PNM + #endif +#endif + +#if defined(STBI_NO_PNG) && !defined(STBI_SUPPORT_ZLIB) && !defined(STBI_NO_ZLIB) +#define STBI_NO_ZLIB +#endif + + +#include +#include // ptrdiff_t on osx +#include +#include +#include + +#if !defined(STBI_NO_LINEAR) || !defined(STBI_NO_HDR) +#include // ldexp, pow +#endif + +#ifndef STBI_NO_STDIO +#include +#endif + +#ifndef STBI_ASSERT +#include +#define STBI_ASSERT(x) assert(x) +#endif + +#ifdef __cplusplus +#define STBI_EXTERN extern "C" +#else +#define STBI_EXTERN extern +#endif + + +#ifndef _MSC_VER + #ifdef __cplusplus + #define stbi_inline inline + #else + #define stbi_inline + #endif +#else + #define stbi_inline __forceinline +#endif + +#ifndef STBI_NO_THREAD_LOCALS + #if defined(__cplusplus) && __cplusplus >= 201103L + #define STBI_THREAD_LOCAL thread_local + #elif defined(__GNUC__) && __GNUC__ < 5 + #define STBI_THREAD_LOCAL __thread + #elif defined(_MSC_VER) + #define STBI_THREAD_LOCAL __declspec(thread) + #elif defined (__STDC_VERSION__) && __STDC_VERSION__ >= 201112L && !defined(__STDC_NO_THREADS__) + #define STBI_THREAD_LOCAL _Thread_local + #endif + + #ifndef STBI_THREAD_LOCAL + #if defined(__GNUC__) + #define STBI_THREAD_LOCAL __thread + #endif + #endif +#endif + +#ifdef _MSC_VER +typedef unsigned short stbi__uint16; +typedef signed short stbi__int16; +typedef unsigned int stbi__uint32; +typedef signed int stbi__int32; +#else +#include +typedef uint16_t stbi__uint16; +typedef int16_t stbi__int16; +typedef uint32_t stbi__uint32; +typedef int32_t stbi__int32; +#endif + +// should produce compiler error if size is wrong +typedef unsigned char validate_uint32[sizeof(stbi__uint32)==4 ? 1 : -1]; + +#ifdef _MSC_VER +#define STBI_NOTUSED(v) (void)(v) +#else +#define STBI_NOTUSED(v) (void)sizeof(v) +#endif + +#ifdef _MSC_VER +#define STBI_HAS_LROTL +#endif + +#ifdef STBI_HAS_LROTL + #define stbi_lrot(x,y) _lrotl(x,y) +#else + #define stbi_lrot(x,y) (((x) << (y)) | ((x) >> (32 - (y)))) +#endif + +#if defined(STBI_MALLOC) && defined(STBI_FREE) && (defined(STBI_REALLOC) || defined(STBI_REALLOC_SIZED)) +// ok +#elif !defined(STBI_MALLOC) && !defined(STBI_FREE) && !defined(STBI_REALLOC) && !defined(STBI_REALLOC_SIZED) +// ok +#else +#error "Must define all or none of STBI_MALLOC, STBI_FREE, and STBI_REALLOC (or STBI_REALLOC_SIZED)." +#endif + +#ifndef STBI_MALLOC +#define STBI_MALLOC(sz) malloc(sz) +#define STBI_REALLOC(p,newsz) realloc(p,newsz) +#define STBI_FREE(p) free(p) +#endif + +#ifndef STBI_REALLOC_SIZED +#define STBI_REALLOC_SIZED(p,oldsz,newsz) STBI_REALLOC(p,newsz) +#endif + +// x86/x64 detection +#if defined(__x86_64__) || defined(_M_X64) +#define STBI__X64_TARGET +#elif defined(__i386) || defined(_M_IX86) +#define STBI__X86_TARGET +#endif + +#if defined(__GNUC__) && defined(STBI__X86_TARGET) && !defined(__SSE2__) && !defined(STBI_NO_SIMD) +// gcc doesn't support sse2 intrinsics unless you compile with -msse2, +// which in turn means it gets to use SSE2 everywhere. This is unfortunate, +// but previous attempts to provide the SSE2 functions with runtime +// detection caused numerous issues. The way architecture extensions are +// exposed in GCC/Clang is, sadly, not really suited for one-file libs. +// New behavior: if compiled with -msse2, we use SSE2 without any +// detection; if not, we don't use it at all. +#define STBI_NO_SIMD +#endif + +#if defined(__MINGW32__) && defined(STBI__X86_TARGET) && !defined(STBI_MINGW_ENABLE_SSE2) && !defined(STBI_NO_SIMD) +// Note that __MINGW32__ doesn't actually mean 32-bit, so we have to avoid STBI__X64_TARGET +// +// 32-bit MinGW wants ESP to be 16-byte aligned, but this is not in the +// Windows ABI and VC++ as well as Windows DLLs don't maintain that invariant. +// As a result, enabling SSE2 on 32-bit MinGW is dangerous when not +// simultaneously enabling "-mstackrealign". +// +// See https://github.com/nothings/stb/issues/81 for more information. +// +// So default to no SSE2 on 32-bit MinGW. If you've read this far and added +// -mstackrealign to your build settings, feel free to #define STBI_MINGW_ENABLE_SSE2. +#define STBI_NO_SIMD +#endif + +#if !defined(STBI_NO_SIMD) && (defined(STBI__X86_TARGET) || defined(STBI__X64_TARGET)) +#define STBI_SSE2 +#include + +#ifdef _MSC_VER + +#if _MSC_VER >= 1400 // not VC6 +#include // __cpuid +static int stbi__cpuid3(void) +{ + int info[4]; + __cpuid(info,1); + return info[3]; +} +#else +static int stbi__cpuid3(void) +{ + int res; + __asm { + mov eax,1 + cpuid + mov res,edx + } + return res; +} +#endif + +#define STBI_SIMD_ALIGN(type, name) __declspec(align(16)) type name + +#if !defined(STBI_NO_JPEG) && defined(STBI_SSE2) +static int stbi__sse2_available(void) +{ + int info3 = stbi__cpuid3(); + return ((info3 >> 26) & 1) != 0; +} +#endif + +#else // assume GCC-style if not VC++ +#define STBI_SIMD_ALIGN(type, name) type name __attribute__((aligned(16))) + +#if !defined(STBI_NO_JPEG) && defined(STBI_SSE2) +static int stbi__sse2_available(void) +{ + // If we're even attempting to compile this on GCC/Clang, that means + // -msse2 is on, which means the compiler is allowed to use SSE2 + // instructions at will, and so are we. + return 1; +} +#endif + +#endif +#endif + +// ARM NEON +#if defined(STBI_NO_SIMD) && defined(STBI_NEON) +#undef STBI_NEON +#endif + +#ifdef STBI_NEON +#include +// assume GCC or Clang on ARM targets +#define STBI_SIMD_ALIGN(type, name) type name __attribute__((aligned(16))) +#endif + +#ifndef STBI_SIMD_ALIGN +#define STBI_SIMD_ALIGN(type, name) type name +#endif + +#ifndef STBI_MAX_DIMENSIONS +#define STBI_MAX_DIMENSIONS (1 << 24) +#endif + +/////////////////////////////////////////////// +// +// stbi__context struct and start_xxx functions + +// stbi__context structure is our basic context used by all images, so it +// contains all the IO context, plus some basic image information +typedef struct +{ + stbi__uint32 img_x, img_y; + int img_n, img_out_n; + + stbi_io_callbacks io; + void *io_user_data; + + int read_from_callbacks; + int buflen; + stbi_uc buffer_start[128]; + int callback_already_read; + + stbi_uc *img_buffer, *img_buffer_end; + stbi_uc *img_buffer_original, *img_buffer_original_end; +} stbi__context; + + +static void stbi__refill_buffer(stbi__context *s); + +// initialize a memory-decode context +static void stbi__start_mem(stbi__context *s, stbi_uc const *buffer, int len) +{ + s->io.read = NULL; + s->read_from_callbacks = 0; + s->callback_already_read = 0; + s->img_buffer = s->img_buffer_original = (stbi_uc *) buffer; + s->img_buffer_end = s->img_buffer_original_end = (stbi_uc *) buffer+len; +} + +// initialize a callback-based context +static void stbi__start_callbacks(stbi__context *s, stbi_io_callbacks *c, void *user) +{ + s->io = *c; + s->io_user_data = user; + s->buflen = sizeof(s->buffer_start); + s->read_from_callbacks = 1; + s->callback_already_read = 0; + s->img_buffer = s->img_buffer_original = s->buffer_start; + stbi__refill_buffer(s); + s->img_buffer_original_end = s->img_buffer_end; +} + +#ifndef STBI_NO_STDIO + +static int stbi__stdio_read(void *user, char *data, int size) +{ + return (int) fread(data,1,size,(FILE*) user); +} + +static void stbi__stdio_skip(void *user, int n) +{ + int ch; + fseek((FILE*) user, n, SEEK_CUR); + ch = fgetc((FILE*) user); /* have to read a byte to reset feof()'s flag */ + if (ch != EOF) { + ungetc(ch, (FILE *) user); /* push byte back onto stream if valid. */ + } +} + +static int stbi__stdio_eof(void *user) +{ + return feof((FILE*) user) || ferror((FILE *) user); +} + +static stbi_io_callbacks stbi__stdio_callbacks = +{ + stbi__stdio_read, + stbi__stdio_skip, + stbi__stdio_eof, +}; + +static void stbi__start_file(stbi__context *s, FILE *f) +{ + stbi__start_callbacks(s, &stbi__stdio_callbacks, (void *) f); +} + +//static void stop_file(stbi__context *s) { } + +#endif // !STBI_NO_STDIO + +static void stbi__rewind(stbi__context *s) +{ + // conceptually rewind SHOULD rewind to the beginning of the stream, + // but we just rewind to the beginning of the initial buffer, because + // we only use it after doing 'test', which only ever looks at at most 92 bytes + s->img_buffer = s->img_buffer_original; + s->img_buffer_end = s->img_buffer_original_end; +} + +enum +{ + STBI_ORDER_RGB, + STBI_ORDER_BGR +}; + +typedef struct +{ + int bits_per_channel; + int num_channels; + int channel_order; +} stbi__result_info; + +#ifndef STBI_NO_JPEG +static int stbi__jpeg_test(stbi__context *s); +static void *stbi__jpeg_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static int stbi__jpeg_info(stbi__context *s, int *x, int *y, int *comp); +#endif + +#ifndef STBI_NO_PNG +static int stbi__png_test(stbi__context *s); +static void *stbi__png_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static int stbi__png_info(stbi__context *s, int *x, int *y, int *comp); +static int stbi__png_is16(stbi__context *s); +#endif + +#ifndef STBI_NO_BMP +static int stbi__bmp_test(stbi__context *s); +static void *stbi__bmp_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static int stbi__bmp_info(stbi__context *s, int *x, int *y, int *comp); +#endif + +#ifndef STBI_NO_TGA +static int stbi__tga_test(stbi__context *s); +static void *stbi__tga_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static int stbi__tga_info(stbi__context *s, int *x, int *y, int *comp); +#endif + +#ifndef STBI_NO_PSD +static int stbi__psd_test(stbi__context *s); +static void *stbi__psd_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri, int bpc); +static int stbi__psd_info(stbi__context *s, int *x, int *y, int *comp); +static int stbi__psd_is16(stbi__context *s); +#endif + +#ifndef STBI_NO_HDR +static int stbi__hdr_test(stbi__context *s); +static float *stbi__hdr_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static int stbi__hdr_info(stbi__context *s, int *x, int *y, int *comp); +#endif + +#ifndef STBI_NO_PIC +static int stbi__pic_test(stbi__context *s); +static void *stbi__pic_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static int stbi__pic_info(stbi__context *s, int *x, int *y, int *comp); +#endif + +#ifndef STBI_NO_GIF +static int stbi__gif_test(stbi__context *s); +static void *stbi__gif_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static void *stbi__load_gif_main(stbi__context *s, int **delays, int *x, int *y, int *z, int *comp, int req_comp); +static int stbi__gif_info(stbi__context *s, int *x, int *y, int *comp); +#endif + +#ifndef STBI_NO_PNM +static int stbi__pnm_test(stbi__context *s); +static void *stbi__pnm_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static int stbi__pnm_info(stbi__context *s, int *x, int *y, int *comp); +#endif + +static +#ifdef STBI_THREAD_LOCAL +STBI_THREAD_LOCAL +#endif +const char *stbi__g_failure_reason; + +STBIDEF const char *stbi_failure_reason(void) +{ + return stbi__g_failure_reason; +} + +#ifndef STBI_NO_FAILURE_STRINGS +static int stbi__err(const char *str) +{ + stbi__g_failure_reason = str; + return 0; +} +#endif + +static void *stbi__malloc(size_t size) +{ + return STBI_MALLOC(size); +} + +// stb_image uses ints pervasively, including for offset calculations. +// therefore the largest decoded image size we can support with the +// current code, even on 64-bit targets, is INT_MAX. this is not a +// significant limitation for the intended use case. +// +// we do, however, need to make sure our size calculations don't +// overflow. hence a few helper functions for size calculations that +// multiply integers together, making sure that they're non-negative +// and no overflow occurs. + +// return 1 if the sum is valid, 0 on overflow. +// negative terms are considered invalid. +static int stbi__addsizes_valid(int a, int b) +{ + if (b < 0) return 0; + // now 0 <= b <= INT_MAX, hence also + // 0 <= INT_MAX - b <= INTMAX. + // And "a + b <= INT_MAX" (which might overflow) is the + // same as a <= INT_MAX - b (no overflow) + return a <= INT_MAX - b; +} + +// returns 1 if the product is valid, 0 on overflow. +// negative factors are considered invalid. +static int stbi__mul2sizes_valid(int a, int b) +{ + if (a < 0 || b < 0) return 0; + if (b == 0) return 1; // mul-by-0 is always safe + // portable way to check for no overflows in a*b + return a <= INT_MAX/b; +} + +#if !defined(STBI_NO_JPEG) || !defined(STBI_NO_PNG) || !defined(STBI_NO_TGA) || !defined(STBI_NO_HDR) +// returns 1 if "a*b + add" has no negative terms/factors and doesn't overflow +static int stbi__mad2sizes_valid(int a, int b, int add) +{ + return stbi__mul2sizes_valid(a, b) && stbi__addsizes_valid(a*b, add); +} +#endif + +// returns 1 if "a*b*c + add" has no negative terms/factors and doesn't overflow +static int stbi__mad3sizes_valid(int a, int b, int c, int add) +{ + return stbi__mul2sizes_valid(a, b) && stbi__mul2sizes_valid(a*b, c) && + stbi__addsizes_valid(a*b*c, add); +} + +// returns 1 if "a*b*c*d + add" has no negative terms/factors and doesn't overflow +#if !defined(STBI_NO_LINEAR) || !defined(STBI_NO_HDR) +static int stbi__mad4sizes_valid(int a, int b, int c, int d, int add) +{ + return stbi__mul2sizes_valid(a, b) && stbi__mul2sizes_valid(a*b, c) && + stbi__mul2sizes_valid(a*b*c, d) && stbi__addsizes_valid(a*b*c*d, add); +} +#endif + +#if !defined(STBI_NO_JPEG) || !defined(STBI_NO_PNG) || !defined(STBI_NO_TGA) || !defined(STBI_NO_HDR) +// mallocs with size overflow checking +static void *stbi__malloc_mad2(int a, int b, int add) +{ + if (!stbi__mad2sizes_valid(a, b, add)) return NULL; + return stbi__malloc(a*b + add); +} +#endif + +static void *stbi__malloc_mad3(int a, int b, int c, int add) +{ + if (!stbi__mad3sizes_valid(a, b, c, add)) return NULL; + return stbi__malloc(a*b*c + add); +} + +#if !defined(STBI_NO_LINEAR) || !defined(STBI_NO_HDR) +static void *stbi__malloc_mad4(int a, int b, int c, int d, int add) +{ + if (!stbi__mad4sizes_valid(a, b, c, d, add)) return NULL; + return stbi__malloc(a*b*c*d + add); +} +#endif + +// stbi__err - error +// stbi__errpf - error returning pointer to float +// stbi__errpuc - error returning pointer to unsigned char + +#ifdef STBI_NO_FAILURE_STRINGS + #define stbi__err(x,y) 0 +#elif defined(STBI_FAILURE_USERMSG) + #define stbi__err(x,y) stbi__err(y) +#else + #define stbi__err(x,y) stbi__err(x) +#endif + +#define stbi__errpf(x,y) ((float *)(size_t) (stbi__err(x,y)?NULL:NULL)) +#define stbi__errpuc(x,y) ((unsigned char *)(size_t) (stbi__err(x,y)?NULL:NULL)) + +STBIDEF void stbi_image_free(void *retval_from_stbi_load) +{ + STBI_FREE(retval_from_stbi_load); +} + +#ifndef STBI_NO_LINEAR +static float *stbi__ldr_to_hdr(stbi_uc *data, int x, int y, int comp); +#endif + +#ifndef STBI_NO_HDR +static stbi_uc *stbi__hdr_to_ldr(float *data, int x, int y, int comp); +#endif + +static int stbi__vertically_flip_on_load_global = 0; + +STBIDEF void stbi_set_flip_vertically_on_load(int flag_true_if_should_flip) +{ + stbi__vertically_flip_on_load_global = flag_true_if_should_flip; +} + +#ifndef STBI_THREAD_LOCAL +#define stbi__vertically_flip_on_load stbi__vertically_flip_on_load_global +#else +static STBI_THREAD_LOCAL int stbi__vertically_flip_on_load_local, stbi__vertically_flip_on_load_set; + +STBIDEF void stbi_set_flip_vertically_on_load_thread(int flag_true_if_should_flip) +{ + stbi__vertically_flip_on_load_local = flag_true_if_should_flip; + stbi__vertically_flip_on_load_set = 1; +} + +#define stbi__vertically_flip_on_load (stbi__vertically_flip_on_load_set \ + ? stbi__vertically_flip_on_load_local \ + : stbi__vertically_flip_on_load_global) +#endif // STBI_THREAD_LOCAL + +static void *stbi__load_main(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri, int bpc) +{ + memset(ri, 0, sizeof(*ri)); // make sure it's initialized if we add new fields + ri->bits_per_channel = 8; // default is 8 so most paths don't have to be changed + ri->channel_order = STBI_ORDER_RGB; // all current input & output are this, but this is here so we can add BGR order + ri->num_channels = 0; + + #ifndef STBI_NO_JPEG + if (stbi__jpeg_test(s)) return stbi__jpeg_load(s,x,y,comp,req_comp, ri); + #endif + #ifndef STBI_NO_PNG + if (stbi__png_test(s)) return stbi__png_load(s,x,y,comp,req_comp, ri); + #endif + #ifndef STBI_NO_BMP + if (stbi__bmp_test(s)) return stbi__bmp_load(s,x,y,comp,req_comp, ri); + #endif + #ifndef STBI_NO_GIF + if (stbi__gif_test(s)) return stbi__gif_load(s,x,y,comp,req_comp, ri); + #endif + #ifndef STBI_NO_PSD + if (stbi__psd_test(s)) return stbi__psd_load(s,x,y,comp,req_comp, ri, bpc); + #else + STBI_NOTUSED(bpc); + #endif + #ifndef STBI_NO_PIC + if (stbi__pic_test(s)) return stbi__pic_load(s,x,y,comp,req_comp, ri); + #endif + #ifndef STBI_NO_PNM + if (stbi__pnm_test(s)) return stbi__pnm_load(s,x,y,comp,req_comp, ri); + #endif + + #ifndef STBI_NO_HDR + if (stbi__hdr_test(s)) { + float *hdr = stbi__hdr_load(s, x,y,comp,req_comp, ri); + return stbi__hdr_to_ldr(hdr, *x, *y, req_comp ? req_comp : *comp); + } + #endif + + #ifndef STBI_NO_TGA + // test tga last because it's a crappy test! + if (stbi__tga_test(s)) + return stbi__tga_load(s,x,y,comp,req_comp, ri); + #endif + + return stbi__errpuc("unknown image type", "Image not of any known type, or corrupt"); +} + +static stbi_uc *stbi__convert_16_to_8(stbi__uint16 *orig, int w, int h, int channels) +{ + int i; + int img_len = w * h * channels; + stbi_uc *reduced; + + reduced = (stbi_uc *) stbi__malloc(img_len); + if (reduced == NULL) return stbi__errpuc("outofmem", "Out of memory"); + + for (i = 0; i < img_len; ++i) + reduced[i] = (stbi_uc)((orig[i] >> 8) & 0xFF); // top half of each byte is sufficient approx of 16->8 bit scaling + + STBI_FREE(orig); + return reduced; +} + +static stbi__uint16 *stbi__convert_8_to_16(stbi_uc *orig, int w, int h, int channels) +{ + int i; + int img_len = w * h * channels; + stbi__uint16 *enlarged; + + enlarged = (stbi__uint16 *) stbi__malloc(img_len*2); + if (enlarged == NULL) return (stbi__uint16 *) stbi__errpuc("outofmem", "Out of memory"); + + for (i = 0; i < img_len; ++i) + enlarged[i] = (stbi__uint16)((orig[i] << 8) + orig[i]); // replicate to high and low byte, maps 0->0, 255->0xffff + + STBI_FREE(orig); + return enlarged; +} + +static void stbi__vertical_flip(void *image, int w, int h, int bytes_per_pixel) +{ + int row; + size_t bytes_per_row = (size_t)w * bytes_per_pixel; + stbi_uc temp[2048]; + stbi_uc *bytes = (stbi_uc *)image; + + for (row = 0; row < (h>>1); row++) { + stbi_uc *row0 = bytes + row*bytes_per_row; + stbi_uc *row1 = bytes + (h - row - 1)*bytes_per_row; + // swap row0 with row1 + size_t bytes_left = bytes_per_row; + while (bytes_left) { + size_t bytes_copy = (bytes_left < sizeof(temp)) ? bytes_left : sizeof(temp); + memcpy(temp, row0, bytes_copy); + memcpy(row0, row1, bytes_copy); + memcpy(row1, temp, bytes_copy); + row0 += bytes_copy; + row1 += bytes_copy; + bytes_left -= bytes_copy; + } + } +} + +#ifndef STBI_NO_GIF +static void stbi__vertical_flip_slices(void *image, int w, int h, int z, int bytes_per_pixel) +{ + int slice; + int slice_size = w * h * bytes_per_pixel; + + stbi_uc *bytes = (stbi_uc *)image; + for (slice = 0; slice < z; ++slice) { + stbi__vertical_flip(bytes, w, h, bytes_per_pixel); + bytes += slice_size; + } +} +#endif + +static unsigned char *stbi__load_and_postprocess_8bit(stbi__context *s, int *x, int *y, int *comp, int req_comp) +{ + stbi__result_info ri; + void *result = stbi__load_main(s, x, y, comp, req_comp, &ri, 8); + + if (result == NULL) + return NULL; + + // it is the responsibility of the loaders to make sure we get either 8 or 16 bit. + STBI_ASSERT(ri.bits_per_channel == 8 || ri.bits_per_channel == 16); + + if (ri.bits_per_channel != 8) { + result = stbi__convert_16_to_8((stbi__uint16 *) result, *x, *y, req_comp == 0 ? *comp : req_comp); + ri.bits_per_channel = 8; + } + + // @TODO: move stbi__convert_format to here + + if (stbi__vertically_flip_on_load) { + int channels = req_comp ? req_comp : *comp; + stbi__vertical_flip(result, *x, *y, channels * sizeof(stbi_uc)); + } + + return (unsigned char *) result; +} + +static stbi__uint16 *stbi__load_and_postprocess_16bit(stbi__context *s, int *x, int *y, int *comp, int req_comp) +{ + stbi__result_info ri; + void *result = stbi__load_main(s, x, y, comp, req_comp, &ri, 16); + + if (result == NULL) + return NULL; + + // it is the responsibility of the loaders to make sure we get either 8 or 16 bit. + STBI_ASSERT(ri.bits_per_channel == 8 || ri.bits_per_channel == 16); + + if (ri.bits_per_channel != 16) { + result = stbi__convert_8_to_16((stbi_uc *) result, *x, *y, req_comp == 0 ? *comp : req_comp); + ri.bits_per_channel = 16; + } + + // @TODO: move stbi__convert_format16 to here + // @TODO: special case RGB-to-Y (and RGBA-to-YA) for 8-bit-to-16-bit case to keep more precision + + if (stbi__vertically_flip_on_load) { + int channels = req_comp ? req_comp : *comp; + stbi__vertical_flip(result, *x, *y, channels * sizeof(stbi__uint16)); + } + + return (stbi__uint16 *) result; +} + +#if !defined(STBI_NO_HDR) && !defined(STBI_NO_LINEAR) +static void stbi__float_postprocess(float *result, int *x, int *y, int *comp, int req_comp) +{ + if (stbi__vertically_flip_on_load && result != NULL) { + int channels = req_comp ? req_comp : *comp; + stbi__vertical_flip(result, *x, *y, channels * sizeof(float)); + } +} +#endif + +#ifndef STBI_NO_STDIO + +#if defined(_MSC_VER) && defined(STBI_WINDOWS_UTF8) +STBI_EXTERN __declspec(dllimport) int __stdcall MultiByteToWideChar(unsigned int cp, unsigned long flags, const char *str, int cbmb, wchar_t *widestr, int cchwide); +STBI_EXTERN __declspec(dllimport) int __stdcall WideCharToMultiByte(unsigned int cp, unsigned long flags, const wchar_t *widestr, int cchwide, char *str, int cbmb, const char *defchar, int *used_default); +#endif + +#if defined(_MSC_VER) && defined(STBI_WINDOWS_UTF8) +STBIDEF int stbi_convert_wchar_to_utf8(char *buffer, size_t bufferlen, const wchar_t* input) +{ + return WideCharToMultiByte(65001 /* UTF8 */, 0, input, -1, buffer, (int) bufferlen, NULL, NULL); +} +#endif + +static FILE *stbi__fopen(char const *filename, char const *mode) +{ + FILE *f; +#if defined(_MSC_VER) && defined(STBI_WINDOWS_UTF8) + wchar_t wMode[64]; + wchar_t wFilename[1024]; + if (0 == MultiByteToWideChar(65001 /* UTF8 */, 0, filename, -1, wFilename, sizeof(wFilename))) + return 0; + + if (0 == MultiByteToWideChar(65001 /* UTF8 */, 0, mode, -1, wMode, sizeof(wMode))) + return 0; + +#if _MSC_VER >= 1400 + if (0 != _wfopen_s(&f, wFilename, wMode)) + f = 0; +#else + f = _wfopen(wFilename, wMode); +#endif + +#elif defined(_MSC_VER) && _MSC_VER >= 1400 + if (0 != fopen_s(&f, filename, mode)) + f=0; +#else + f = fopen(filename, mode); +#endif + return f; +} + + +STBIDEF stbi_uc *stbi_load(char const *filename, int *x, int *y, int *comp, int req_comp) +{ + FILE *f = stbi__fopen(filename, "rb"); + unsigned char *result; + if (!f) return stbi__errpuc("can't fopen", "Unable to open file"); + result = stbi_load_from_file(f,x,y,comp,req_comp); + fclose(f); + return result; +} + +STBIDEF stbi_uc *stbi_load_from_file(FILE *f, int *x, int *y, int *comp, int req_comp) +{ + unsigned char *result; + stbi__context s; + stbi__start_file(&s,f); + result = stbi__load_and_postprocess_8bit(&s,x,y,comp,req_comp); + if (result) { + // need to 'unget' all the characters in the IO buffer + fseek(f, - (int) (s.img_buffer_end - s.img_buffer), SEEK_CUR); + } + return result; +} + +STBIDEF stbi__uint16 *stbi_load_from_file_16(FILE *f, int *x, int *y, int *comp, int req_comp) +{ + stbi__uint16 *result; + stbi__context s; + stbi__start_file(&s,f); + result = stbi__load_and_postprocess_16bit(&s,x,y,comp,req_comp); + if (result) { + // need to 'unget' all the characters in the IO buffer + fseek(f, - (int) (s.img_buffer_end - s.img_buffer), SEEK_CUR); + } + return result; +} + +STBIDEF stbi_us *stbi_load_16(char const *filename, int *x, int *y, int *comp, int req_comp) +{ + FILE *f = stbi__fopen(filename, "rb"); + stbi__uint16 *result; + if (!f) return (stbi_us *) stbi__errpuc("can't fopen", "Unable to open file"); + result = stbi_load_from_file_16(f,x,y,comp,req_comp); + fclose(f); + return result; +} + + +#endif //!STBI_NO_STDIO + +STBIDEF stbi_us *stbi_load_16_from_memory(stbi_uc const *buffer, int len, int *x, int *y, int *channels_in_file, int desired_channels) +{ + stbi__context s; + stbi__start_mem(&s,buffer,len); + return stbi__load_and_postprocess_16bit(&s,x,y,channels_in_file,desired_channels); +} + +STBIDEF stbi_us *stbi_load_16_from_callbacks(stbi_io_callbacks const *clbk, void *user, int *x, int *y, int *channels_in_file, int desired_channels) +{ + stbi__context s; + stbi__start_callbacks(&s, (stbi_io_callbacks *)clbk, user); + return stbi__load_and_postprocess_16bit(&s,x,y,channels_in_file,desired_channels); +} + +STBIDEF stbi_uc *stbi_load_from_memory(stbi_uc const *buffer, int len, int *x, int *y, int *comp, int req_comp) +{ + stbi__context s; + stbi__start_mem(&s,buffer,len); + return stbi__load_and_postprocess_8bit(&s,x,y,comp,req_comp); +} + +STBIDEF stbi_uc *stbi_load_from_callbacks(stbi_io_callbacks const *clbk, void *user, int *x, int *y, int *comp, int req_comp) +{ + stbi__context s; + stbi__start_callbacks(&s, (stbi_io_callbacks *) clbk, user); + return stbi__load_and_postprocess_8bit(&s,x,y,comp,req_comp); +} + +#ifndef STBI_NO_GIF +STBIDEF stbi_uc *stbi_load_gif_from_memory(stbi_uc const *buffer, int len, int **delays, int *x, int *y, int *z, int *comp, int req_comp) +{ + unsigned char *result; + stbi__context s; + stbi__start_mem(&s,buffer,len); + + result = (unsigned char*) stbi__load_gif_main(&s, delays, x, y, z, comp, req_comp); + if (stbi__vertically_flip_on_load) { + stbi__vertical_flip_slices( result, *x, *y, *z, *comp ); + } + + return result; +} +#endif + +#ifndef STBI_NO_LINEAR +static float *stbi__loadf_main(stbi__context *s, int *x, int *y, int *comp, int req_comp) +{ + unsigned char *data; + #ifndef STBI_NO_HDR + if (stbi__hdr_test(s)) { + stbi__result_info ri; + float *hdr_data = stbi__hdr_load(s,x,y,comp,req_comp, &ri); + if (hdr_data) + stbi__float_postprocess(hdr_data,x,y,comp,req_comp); + return hdr_data; + } + #endif + data = stbi__load_and_postprocess_8bit(s, x, y, comp, req_comp); + if (data) + return stbi__ldr_to_hdr(data, *x, *y, req_comp ? req_comp : *comp); + return stbi__errpf("unknown image type", "Image not of any known type, or corrupt"); +} + +STBIDEF float *stbi_loadf_from_memory(stbi_uc const *buffer, int len, int *x, int *y, int *comp, int req_comp) +{ + stbi__context s; + stbi__start_mem(&s,buffer,len); + return stbi__loadf_main(&s,x,y,comp,req_comp); +} + +STBIDEF float *stbi_loadf_from_callbacks(stbi_io_callbacks const *clbk, void *user, int *x, int *y, int *comp, int req_comp) +{ + stbi__context s; + stbi__start_callbacks(&s, (stbi_io_callbacks *) clbk, user); + return stbi__loadf_main(&s,x,y,comp,req_comp); +} + +#ifndef STBI_NO_STDIO +STBIDEF float *stbi_loadf(char const *filename, int *x, int *y, int *comp, int req_comp) +{ + float *result; + FILE *f = stbi__fopen(filename, "rb"); + if (!f) return stbi__errpf("can't fopen", "Unable to open file"); + result = stbi_loadf_from_file(f,x,y,comp,req_comp); + fclose(f); + return result; +} + +STBIDEF float *stbi_loadf_from_file(FILE *f, int *x, int *y, int *comp, int req_comp) +{ + stbi__context s; + stbi__start_file(&s,f); + return stbi__loadf_main(&s,x,y,comp,req_comp); +} +#endif // !STBI_NO_STDIO + +#endif // !STBI_NO_LINEAR + +// these is-hdr-or-not is defined independent of whether STBI_NO_LINEAR is +// defined, for API simplicity; if STBI_NO_LINEAR is defined, it always +// reports false! + +STBIDEF int stbi_is_hdr_from_memory(stbi_uc const *buffer, int len) +{ + #ifndef STBI_NO_HDR + stbi__context s; + stbi__start_mem(&s,buffer,len); + return stbi__hdr_test(&s); + #else + STBI_NOTUSED(buffer); + STBI_NOTUSED(len); + return 0; + #endif +} + +#ifndef STBI_NO_STDIO +STBIDEF int stbi_is_hdr (char const *filename) +{ + FILE *f = stbi__fopen(filename, "rb"); + int result=0; + if (f) { + result = stbi_is_hdr_from_file(f); + fclose(f); + } + return result; +} + +STBIDEF int stbi_is_hdr_from_file(FILE *f) +{ + #ifndef STBI_NO_HDR + long pos = ftell(f); + int res; + stbi__context s; + stbi__start_file(&s,f); + res = stbi__hdr_test(&s); + fseek(f, pos, SEEK_SET); + return res; + #else + STBI_NOTUSED(f); + return 0; + #endif +} +#endif // !STBI_NO_STDIO + +STBIDEF int stbi_is_hdr_from_callbacks(stbi_io_callbacks const *clbk, void *user) +{ + #ifndef STBI_NO_HDR + stbi__context s; + stbi__start_callbacks(&s, (stbi_io_callbacks *) clbk, user); + return stbi__hdr_test(&s); + #else + STBI_NOTUSED(clbk); + STBI_NOTUSED(user); + return 0; + #endif +} + +#ifndef STBI_NO_LINEAR +static float stbi__l2h_gamma=2.2f, stbi__l2h_scale=1.0f; + +STBIDEF void stbi_ldr_to_hdr_gamma(float gamma) { stbi__l2h_gamma = gamma; } +STBIDEF void stbi_ldr_to_hdr_scale(float scale) { stbi__l2h_scale = scale; } +#endif + +static float stbi__h2l_gamma_i=1.0f/2.2f, stbi__h2l_scale_i=1.0f; + +STBIDEF void stbi_hdr_to_ldr_gamma(float gamma) { stbi__h2l_gamma_i = 1/gamma; } +STBIDEF void stbi_hdr_to_ldr_scale(float scale) { stbi__h2l_scale_i = 1/scale; } + + +////////////////////////////////////////////////////////////////////////////// +// +// Common code used by all image loaders +// + +enum +{ + STBI__SCAN_load=0, + STBI__SCAN_type, + STBI__SCAN_header +}; + +static void stbi__refill_buffer(stbi__context *s) +{ + int n = (s->io.read)(s->io_user_data,(char*)s->buffer_start,s->buflen); + s->callback_already_read += (int) (s->img_buffer - s->img_buffer_original); + if (n == 0) { + // at end of file, treat same as if from memory, but need to handle case + // where s->img_buffer isn't pointing to safe memory, e.g. 0-byte file + s->read_from_callbacks = 0; + s->img_buffer = s->buffer_start; + s->img_buffer_end = s->buffer_start+1; + *s->img_buffer = 0; + } else { + s->img_buffer = s->buffer_start; + s->img_buffer_end = s->buffer_start + n; + } +} + +stbi_inline static stbi_uc stbi__get8(stbi__context *s) +{ + if (s->img_buffer < s->img_buffer_end) + return *s->img_buffer++; + if (s->read_from_callbacks) { + stbi__refill_buffer(s); + return *s->img_buffer++; + } + return 0; +} + +#if defined(STBI_NO_JPEG) && defined(STBI_NO_HDR) && defined(STBI_NO_PIC) && defined(STBI_NO_PNM) +// nothing +#else +stbi_inline static int stbi__at_eof(stbi__context *s) +{ + if (s->io.read) { + if (!(s->io.eof)(s->io_user_data)) return 0; + // if feof() is true, check if buffer = end + // special case: we've only got the special 0 character at the end + if (s->read_from_callbacks == 0) return 1; + } + + return s->img_buffer >= s->img_buffer_end; +} +#endif + +#if defined(STBI_NO_JPEG) && defined(STBI_NO_PNG) && defined(STBI_NO_BMP) && defined(STBI_NO_PSD) && defined(STBI_NO_TGA) && defined(STBI_NO_GIF) && defined(STBI_NO_PIC) +// nothing +#else +static void stbi__skip(stbi__context *s, int n) +{ + if (n == 0) return; // already there! + if (n < 0) { + s->img_buffer = s->img_buffer_end; + return; + } + if (s->io.read) { + int blen = (int) (s->img_buffer_end - s->img_buffer); + if (blen < n) { + s->img_buffer = s->img_buffer_end; + (s->io.skip)(s->io_user_data, n - blen); + return; + } + } + s->img_buffer += n; +} +#endif + +#if defined(STBI_NO_PNG) && defined(STBI_NO_TGA) && defined(STBI_NO_HDR) && defined(STBI_NO_PNM) +// nothing +#else +static int stbi__getn(stbi__context *s, stbi_uc *buffer, int n) +{ + if (s->io.read) { + int blen = (int) (s->img_buffer_end - s->img_buffer); + if (blen < n) { + int res, count; + + memcpy(buffer, s->img_buffer, blen); + + count = (s->io.read)(s->io_user_data, (char*) buffer + blen, n - blen); + res = (count == (n-blen)); + s->img_buffer = s->img_buffer_end; + return res; + } + } + + if (s->img_buffer+n <= s->img_buffer_end) { + memcpy(buffer, s->img_buffer, n); + s->img_buffer += n; + return 1; + } else + return 0; +} +#endif + +#if defined(STBI_NO_JPEG) && defined(STBI_NO_PNG) && defined(STBI_NO_PSD) && defined(STBI_NO_PIC) +// nothing +#else +static int stbi__get16be(stbi__context *s) +{ + int z = stbi__get8(s); + return (z << 8) + stbi__get8(s); +} +#endif + +#if defined(STBI_NO_PNG) && defined(STBI_NO_PSD) && defined(STBI_NO_PIC) +// nothing +#else +static stbi__uint32 stbi__get32be(stbi__context *s) +{ + stbi__uint32 z = stbi__get16be(s); + return (z << 16) + stbi__get16be(s); +} +#endif + +#if defined(STBI_NO_BMP) && defined(STBI_NO_TGA) && defined(STBI_NO_GIF) +// nothing +#else +static int stbi__get16le(stbi__context *s) +{ + int z = stbi__get8(s); + return z + (stbi__get8(s) << 8); +} +#endif + +#ifndef STBI_NO_BMP +static stbi__uint32 stbi__get32le(stbi__context *s) +{ + stbi__uint32 z = stbi__get16le(s); + return z + (stbi__get16le(s) << 16); +} +#endif + +#define STBI__BYTECAST(x) ((stbi_uc) ((x) & 255)) // truncate int to byte without warnings + +#if defined(STBI_NO_JPEG) && defined(STBI_NO_PNG) && defined(STBI_NO_BMP) && defined(STBI_NO_PSD) && defined(STBI_NO_TGA) && defined(STBI_NO_GIF) && defined(STBI_NO_PIC) && defined(STBI_NO_PNM) +// nothing +#else +////////////////////////////////////////////////////////////////////////////// +// +// generic converter from built-in img_n to req_comp +// individual types do this automatically as much as possible (e.g. jpeg +// does all cases internally since it needs to colorspace convert anyway, +// and it never has alpha, so very few cases ). png can automatically +// interleave an alpha=255 channel, but falls back to this for other cases +// +// assume data buffer is malloced, so malloc a new one and free that one +// only failure mode is malloc failing + +static stbi_uc stbi__compute_y(int r, int g, int b) +{ + return (stbi_uc) (((r*77) + (g*150) + (29*b)) >> 8); +} +#endif + +#if defined(STBI_NO_PNG) && defined(STBI_NO_BMP) && defined(STBI_NO_PSD) && defined(STBI_NO_TGA) && defined(STBI_NO_GIF) && defined(STBI_NO_PIC) && defined(STBI_NO_PNM) +// nothing +#else +static unsigned char *stbi__convert_format(unsigned char *data, int img_n, int req_comp, unsigned int x, unsigned int y) +{ + int i,j; + unsigned char *good; + + if (req_comp == img_n) return data; + STBI_ASSERT(req_comp >= 1 && req_comp <= 4); + + good = (unsigned char *) stbi__malloc_mad3(req_comp, x, y, 0); + if (good == NULL) { + STBI_FREE(data); + return stbi__errpuc("outofmem", "Out of memory"); + } + + for (j=0; j < (int) y; ++j) { + unsigned char *src = data + j * x * img_n ; + unsigned char *dest = good + j * x * req_comp; + + #define STBI__COMBO(a,b) ((a)*8+(b)) + #define STBI__CASE(a,b) case STBI__COMBO(a,b): for(i=x-1; i >= 0; --i, src += a, dest += b) + // convert source image with img_n components to one with req_comp components; + // avoid switch per pixel, so use switch per scanline and massive macros + switch (STBI__COMBO(img_n, req_comp)) { + STBI__CASE(1,2) { dest[0]=src[0]; dest[1]=255; } break; + STBI__CASE(1,3) { dest[0]=dest[1]=dest[2]=src[0]; } break; + STBI__CASE(1,4) { dest[0]=dest[1]=dest[2]=src[0]; dest[3]=255; } break; + STBI__CASE(2,1) { dest[0]=src[0]; } break; + STBI__CASE(2,3) { dest[0]=dest[1]=dest[2]=src[0]; } break; + STBI__CASE(2,4) { dest[0]=dest[1]=dest[2]=src[0]; dest[3]=src[1]; } break; + STBI__CASE(3,4) { dest[0]=src[0];dest[1]=src[1];dest[2]=src[2];dest[3]=255; } break; + STBI__CASE(3,1) { dest[0]=stbi__compute_y(src[0],src[1],src[2]); } break; + STBI__CASE(3,2) { dest[0]=stbi__compute_y(src[0],src[1],src[2]); dest[1] = 255; } break; + STBI__CASE(4,1) { dest[0]=stbi__compute_y(src[0],src[1],src[2]); } break; + STBI__CASE(4,2) { dest[0]=stbi__compute_y(src[0],src[1],src[2]); dest[1] = src[3]; } break; + STBI__CASE(4,3) { dest[0]=src[0];dest[1]=src[1];dest[2]=src[2]; } break; + default: STBI_ASSERT(0); STBI_FREE(data); STBI_FREE(good); return stbi__errpuc("unsupported", "Unsupported format conversion"); + } + #undef STBI__CASE + } + + STBI_FREE(data); + return good; +} +#endif + +#if defined(STBI_NO_PNG) && defined(STBI_NO_PSD) +// nothing +#else +static stbi__uint16 stbi__compute_y_16(int r, int g, int b) +{ + return (stbi__uint16) (((r*77) + (g*150) + (29*b)) >> 8); +} +#endif + +#if defined(STBI_NO_PNG) && defined(STBI_NO_PSD) +// nothing +#else +static stbi__uint16 *stbi__convert_format16(stbi__uint16 *data, int img_n, int req_comp, unsigned int x, unsigned int y) +{ + int i,j; + stbi__uint16 *good; + + if (req_comp == img_n) return data; + STBI_ASSERT(req_comp >= 1 && req_comp <= 4); + + good = (stbi__uint16 *) stbi__malloc(req_comp * x * y * 2); + if (good == NULL) { + STBI_FREE(data); + return (stbi__uint16 *) stbi__errpuc("outofmem", "Out of memory"); + } + + for (j=0; j < (int) y; ++j) { + stbi__uint16 *src = data + j * x * img_n ; + stbi__uint16 *dest = good + j * x * req_comp; + + #define STBI__COMBO(a,b) ((a)*8+(b)) + #define STBI__CASE(a,b) case STBI__COMBO(a,b): for(i=x-1; i >= 0; --i, src += a, dest += b) + // convert source image with img_n components to one with req_comp components; + // avoid switch per pixel, so use switch per scanline and massive macros + switch (STBI__COMBO(img_n, req_comp)) { + STBI__CASE(1,2) { dest[0]=src[0]; dest[1]=0xffff; } break; + STBI__CASE(1,3) { dest[0]=dest[1]=dest[2]=src[0]; } break; + STBI__CASE(1,4) { dest[0]=dest[1]=dest[2]=src[0]; dest[3]=0xffff; } break; + STBI__CASE(2,1) { dest[0]=src[0]; } break; + STBI__CASE(2,3) { dest[0]=dest[1]=dest[2]=src[0]; } break; + STBI__CASE(2,4) { dest[0]=dest[1]=dest[2]=src[0]; dest[3]=src[1]; } break; + STBI__CASE(3,4) { dest[0]=src[0];dest[1]=src[1];dest[2]=src[2];dest[3]=0xffff; } break; + STBI__CASE(3,1) { dest[0]=stbi__compute_y_16(src[0],src[1],src[2]); } break; + STBI__CASE(3,2) { dest[0]=stbi__compute_y_16(src[0],src[1],src[2]); dest[1] = 0xffff; } break; + STBI__CASE(4,1) { dest[0]=stbi__compute_y_16(src[0],src[1],src[2]); } break; + STBI__CASE(4,2) { dest[0]=stbi__compute_y_16(src[0],src[1],src[2]); dest[1] = src[3]; } break; + STBI__CASE(4,3) { dest[0]=src[0];dest[1]=src[1];dest[2]=src[2]; } break; + default: STBI_ASSERT(0); STBI_FREE(data); STBI_FREE(good); return (stbi__uint16*) stbi__errpuc("unsupported", "Unsupported format conversion"); + } + #undef STBI__CASE + } + + STBI_FREE(data); + return good; +} +#endif + +#ifndef STBI_NO_LINEAR +static float *stbi__ldr_to_hdr(stbi_uc *data, int x, int y, int comp) +{ + int i,k,n; + float *output; + if (!data) return NULL; + output = (float *) stbi__malloc_mad4(x, y, comp, sizeof(float), 0); + if (output == NULL) { STBI_FREE(data); return stbi__errpf("outofmem", "Out of memory"); } + // compute number of non-alpha components + if (comp & 1) n = comp; else n = comp-1; + for (i=0; i < x*y; ++i) { + for (k=0; k < n; ++k) { + output[i*comp + k] = (float) (pow(data[i*comp+k]/255.0f, stbi__l2h_gamma) * stbi__l2h_scale); + } + } + if (n < comp) { + for (i=0; i < x*y; ++i) { + output[i*comp + n] = data[i*comp + n]/255.0f; + } + } + STBI_FREE(data); + return output; +} +#endif + +#ifndef STBI_NO_HDR +#define stbi__float2int(x) ((int) (x)) +static stbi_uc *stbi__hdr_to_ldr(float *data, int x, int y, int comp) +{ + int i,k,n; + stbi_uc *output; + if (!data) return NULL; + output = (stbi_uc *) stbi__malloc_mad3(x, y, comp, 0); + if (output == NULL) { STBI_FREE(data); return stbi__errpuc("outofmem", "Out of memory"); } + // compute number of non-alpha components + if (comp & 1) n = comp; else n = comp-1; + for (i=0; i < x*y; ++i) { + for (k=0; k < n; ++k) { + float z = (float) pow(data[i*comp+k]*stbi__h2l_scale_i, stbi__h2l_gamma_i) * 255 + 0.5f; + if (z < 0) z = 0; + if (z > 255) z = 255; + output[i*comp + k] = (stbi_uc) stbi__float2int(z); + } + if (k < comp) { + float z = data[i*comp+k] * 255 + 0.5f; + if (z < 0) z = 0; + if (z > 255) z = 255; + output[i*comp + k] = (stbi_uc) stbi__float2int(z); + } + } + STBI_FREE(data); + return output; +} +#endif + +////////////////////////////////////////////////////////////////////////////// +// +// "baseline" JPEG/JFIF decoder +// +// simple implementation +// - doesn't support delayed output of y-dimension +// - simple interface (only one output format: 8-bit interleaved RGB) +// - doesn't try to recover corrupt jpegs +// - doesn't allow partial loading, loading multiple at once +// - still fast on x86 (copying globals into locals doesn't help x86) +// - allocates lots of intermediate memory (full size of all components) +// - non-interleaved case requires this anyway +// - allows good upsampling (see next) +// high-quality +// - upsampled channels are bilinearly interpolated, even across blocks +// - quality integer IDCT derived from IJG's 'slow' +// performance +// - fast huffman; reasonable integer IDCT +// - some SIMD kernels for common paths on targets with SSE2/NEON +// - uses a lot of intermediate memory, could cache poorly + +#ifndef STBI_NO_JPEG + +// huffman decoding acceleration +#define FAST_BITS 9 // larger handles more cases; smaller stomps less cache + +typedef struct +{ + stbi_uc fast[1 << FAST_BITS]; + // weirdly, repacking this into AoS is a 10% speed loss, instead of a win + stbi__uint16 code[256]; + stbi_uc values[256]; + stbi_uc size[257]; + unsigned int maxcode[18]; + int delta[17]; // old 'firstsymbol' - old 'firstcode' +} stbi__huffman; + +typedef struct +{ + stbi__context *s; + stbi__huffman huff_dc[4]; + stbi__huffman huff_ac[4]; + stbi__uint16 dequant[4][64]; + stbi__int16 fast_ac[4][1 << FAST_BITS]; + +// sizes for components, interleaved MCUs + int img_h_max, img_v_max; + int img_mcu_x, img_mcu_y; + int img_mcu_w, img_mcu_h; + +// definition of jpeg image component + struct + { + int id; + int h,v; + int tq; + int hd,ha; + int dc_pred; + + int x,y,w2,h2; + stbi_uc *data; + void *raw_data, *raw_coeff; + stbi_uc *linebuf; + short *coeff; // progressive only + int coeff_w, coeff_h; // number of 8x8 coefficient blocks + } img_comp[4]; + + stbi__uint32 code_buffer; // jpeg entropy-coded buffer + int code_bits; // number of valid bits + unsigned char marker; // marker seen while filling entropy buffer + int nomore; // flag if we saw a marker so must stop + + int progressive; + int spec_start; + int spec_end; + int succ_high; + int succ_low; + int eob_run; + int jfif; + int app14_color_transform; // Adobe APP14 tag + int rgb; + + int scan_n, order[4]; + int restart_interval, todo; + +// kernels + void (*idct_block_kernel)(stbi_uc *out, int out_stride, short data[64]); + void (*YCbCr_to_RGB_kernel)(stbi_uc *out, const stbi_uc *y, const stbi_uc *pcb, const stbi_uc *pcr, int count, int step); + stbi_uc *(*resample_row_hv_2_kernel)(stbi_uc *out, stbi_uc *in_near, stbi_uc *in_far, int w, int hs); +} stbi__jpeg; + +static int stbi__build_huffman(stbi__huffman *h, int *count) +{ + int i,j,k=0; + unsigned int code; + // build size list for each symbol (from JPEG spec) + for (i=0; i < 16; ++i) + for (j=0; j < count[i]; ++j) + h->size[k++] = (stbi_uc) (i+1); + h->size[k] = 0; + + // compute actual symbols (from jpeg spec) + code = 0; + k = 0; + for(j=1; j <= 16; ++j) { + // compute delta to add to code to compute symbol id + h->delta[j] = k - code; + if (h->size[k] == j) { + while (h->size[k] == j) + h->code[k++] = (stbi__uint16) (code++); + if (code-1 >= (1u << j)) return stbi__err("bad code lengths","Corrupt JPEG"); + } + // compute largest code + 1 for this size, preshifted as needed later + h->maxcode[j] = code << (16-j); + code <<= 1; + } + h->maxcode[j] = 0xffffffff; + + // build non-spec acceleration table; 255 is flag for not-accelerated + memset(h->fast, 255, 1 << FAST_BITS); + for (i=0; i < k; ++i) { + int s = h->size[i]; + if (s <= FAST_BITS) { + int c = h->code[i] << (FAST_BITS-s); + int m = 1 << (FAST_BITS-s); + for (j=0; j < m; ++j) { + h->fast[c+j] = (stbi_uc) i; + } + } + } + return 1; +} + +// build a table that decodes both magnitude and value of small ACs in +// one go. +static void stbi__build_fast_ac(stbi__int16 *fast_ac, stbi__huffman *h) +{ + int i; + for (i=0; i < (1 << FAST_BITS); ++i) { + stbi_uc fast = h->fast[i]; + fast_ac[i] = 0; + if (fast < 255) { + int rs = h->values[fast]; + int run = (rs >> 4) & 15; + int magbits = rs & 15; + int len = h->size[fast]; + + if (magbits && len + magbits <= FAST_BITS) { + // magnitude code followed by receive_extend code + int k = ((i << len) & ((1 << FAST_BITS) - 1)) >> (FAST_BITS - magbits); + int m = 1 << (magbits - 1); + if (k < m) k += (~0U << magbits) + 1; + // if the result is small enough, we can fit it in fast_ac table + if (k >= -128 && k <= 127) + fast_ac[i] = (stbi__int16) ((k * 256) + (run * 16) + (len + magbits)); + } + } + } +} + +static void stbi__grow_buffer_unsafe(stbi__jpeg *j) +{ + do { + unsigned int b = j->nomore ? 0 : stbi__get8(j->s); + if (b == 0xff) { + int c = stbi__get8(j->s); + while (c == 0xff) c = stbi__get8(j->s); // consume fill bytes + if (c != 0) { + j->marker = (unsigned char) c; + j->nomore = 1; + return; + } + } + j->code_buffer |= b << (24 - j->code_bits); + j->code_bits += 8; + } while (j->code_bits <= 24); +} + +// (1 << n) - 1 +static const stbi__uint32 stbi__bmask[17]={0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535}; + +// decode a jpeg huffman value from the bitstream +stbi_inline static int stbi__jpeg_huff_decode(stbi__jpeg *j, stbi__huffman *h) +{ + unsigned int temp; + int c,k; + + if (j->code_bits < 16) stbi__grow_buffer_unsafe(j); + + // look at the top FAST_BITS and determine what symbol ID it is, + // if the code is <= FAST_BITS + c = (j->code_buffer >> (32 - FAST_BITS)) & ((1 << FAST_BITS)-1); + k = h->fast[c]; + if (k < 255) { + int s = h->size[k]; + if (s > j->code_bits) + return -1; + j->code_buffer <<= s; + j->code_bits -= s; + return h->values[k]; + } + + // naive test is to shift the code_buffer down so k bits are + // valid, then test against maxcode. To speed this up, we've + // preshifted maxcode left so that it has (16-k) 0s at the + // end; in other words, regardless of the number of bits, it + // wants to be compared against something shifted to have 16; + // that way we don't need to shift inside the loop. + temp = j->code_buffer >> 16; + for (k=FAST_BITS+1 ; ; ++k) + if (temp < h->maxcode[k]) + break; + if (k == 17) { + // error! code not found + j->code_bits -= 16; + return -1; + } + + if (k > j->code_bits) + return -1; + + // convert the huffman code to the symbol id + c = ((j->code_buffer >> (32 - k)) & stbi__bmask[k]) + h->delta[k]; + STBI_ASSERT((((j->code_buffer) >> (32 - h->size[c])) & stbi__bmask[h->size[c]]) == h->code[c]); + + // convert the id to a symbol + j->code_bits -= k; + j->code_buffer <<= k; + return h->values[c]; +} + +// bias[n] = (-1<code_bits < n) stbi__grow_buffer_unsafe(j); + + sgn = (stbi__int32)j->code_buffer >> 31; // sign bit is always in MSB + k = stbi_lrot(j->code_buffer, n); + if (n < 0 || n >= (int) (sizeof(stbi__bmask)/sizeof(*stbi__bmask))) return 0; + j->code_buffer = k & ~stbi__bmask[n]; + k &= stbi__bmask[n]; + j->code_bits -= n; + return k + (stbi__jbias[n] & ~sgn); +} + +// get some unsigned bits +stbi_inline static int stbi__jpeg_get_bits(stbi__jpeg *j, int n) +{ + unsigned int k; + if (j->code_bits < n) stbi__grow_buffer_unsafe(j); + k = stbi_lrot(j->code_buffer, n); + j->code_buffer = k & ~stbi__bmask[n]; + k &= stbi__bmask[n]; + j->code_bits -= n; + return k; +} + +stbi_inline static int stbi__jpeg_get_bit(stbi__jpeg *j) +{ + unsigned int k; + if (j->code_bits < 1) stbi__grow_buffer_unsafe(j); + k = j->code_buffer; + j->code_buffer <<= 1; + --j->code_bits; + return k & 0x80000000; +} + +// given a value that's at position X in the zigzag stream, +// where does it appear in the 8x8 matrix coded as row-major? +static const stbi_uc stbi__jpeg_dezigzag[64+15] = +{ + 0, 1, 8, 16, 9, 2, 3, 10, + 17, 24, 32, 25, 18, 11, 4, 5, + 12, 19, 26, 33, 40, 48, 41, 34, + 27, 20, 13, 6, 7, 14, 21, 28, + 35, 42, 49, 56, 57, 50, 43, 36, + 29, 22, 15, 23, 30, 37, 44, 51, + 58, 59, 52, 45, 38, 31, 39, 46, + 53, 60, 61, 54, 47, 55, 62, 63, + // let corrupt input sample past end + 63, 63, 63, 63, 63, 63, 63, 63, + 63, 63, 63, 63, 63, 63, 63 +}; + +// decode one 64-entry block-- +static int stbi__jpeg_decode_block(stbi__jpeg *j, short data[64], stbi__huffman *hdc, stbi__huffman *hac, stbi__int16 *fac, int b, stbi__uint16 *dequant) +{ + int diff,dc,k; + int t; + + if (j->code_bits < 16) stbi__grow_buffer_unsafe(j); + t = stbi__jpeg_huff_decode(j, hdc); + if (t < 0) return stbi__err("bad huffman code","Corrupt JPEG"); + + // 0 all the ac values now so we can do it 32-bits at a time + memset(data,0,64*sizeof(data[0])); + + diff = t ? stbi__extend_receive(j, t) : 0; + dc = j->img_comp[b].dc_pred + diff; + j->img_comp[b].dc_pred = dc; + data[0] = (short) (dc * dequant[0]); + + // decode AC components, see JPEG spec + k = 1; + do { + unsigned int zig; + int c,r,s; + if (j->code_bits < 16) stbi__grow_buffer_unsafe(j); + c = (j->code_buffer >> (32 - FAST_BITS)) & ((1 << FAST_BITS)-1); + r = fac[c]; + if (r) { // fast-AC path + k += (r >> 4) & 15; // run + s = r & 15; // combined length + j->code_buffer <<= s; + j->code_bits -= s; + // decode into unzigzag'd location + zig = stbi__jpeg_dezigzag[k++]; + data[zig] = (short) ((r >> 8) * dequant[zig]); + } else { + int rs = stbi__jpeg_huff_decode(j, hac); + if (rs < 0) return stbi__err("bad huffman code","Corrupt JPEG"); + s = rs & 15; + r = rs >> 4; + if (s == 0) { + if (rs != 0xf0) break; // end block + k += 16; + } else { + k += r; + // decode into unzigzag'd location + zig = stbi__jpeg_dezigzag[k++]; + data[zig] = (short) (stbi__extend_receive(j,s) * dequant[zig]); + } + } + } while (k < 64); + return 1; +} + +static int stbi__jpeg_decode_block_prog_dc(stbi__jpeg *j, short data[64], stbi__huffman *hdc, int b) +{ + int diff,dc; + int t; + if (j->spec_end != 0) return stbi__err("can't merge dc and ac", "Corrupt JPEG"); + + if (j->code_bits < 16) stbi__grow_buffer_unsafe(j); + + if (j->succ_high == 0) { + // first scan for DC coefficient, must be first + memset(data,0,64*sizeof(data[0])); // 0 all the ac values now + t = stbi__jpeg_huff_decode(j, hdc); + if (t == -1) return stbi__err("can't merge dc and ac", "Corrupt JPEG"); + diff = t ? stbi__extend_receive(j, t) : 0; + + dc = j->img_comp[b].dc_pred + diff; + j->img_comp[b].dc_pred = dc; + data[0] = (short) (dc << j->succ_low); + } else { + // refinement scan for DC coefficient + if (stbi__jpeg_get_bit(j)) + data[0] += (short) (1 << j->succ_low); + } + return 1; +} + +// @OPTIMIZE: store non-zigzagged during the decode passes, +// and only de-zigzag when dequantizing +static int stbi__jpeg_decode_block_prog_ac(stbi__jpeg *j, short data[64], stbi__huffman *hac, stbi__int16 *fac) +{ + int k; + if (j->spec_start == 0) return stbi__err("can't merge dc and ac", "Corrupt JPEG"); + + if (j->succ_high == 0) { + int shift = j->succ_low; + + if (j->eob_run) { + --j->eob_run; + return 1; + } + + k = j->spec_start; + do { + unsigned int zig; + int c,r,s; + if (j->code_bits < 16) stbi__grow_buffer_unsafe(j); + c = (j->code_buffer >> (32 - FAST_BITS)) & ((1 << FAST_BITS)-1); + r = fac[c]; + if (r) { // fast-AC path + k += (r >> 4) & 15; // run + s = r & 15; // combined length + j->code_buffer <<= s; + j->code_bits -= s; + zig = stbi__jpeg_dezigzag[k++]; + data[zig] = (short) ((r >> 8) << shift); + } else { + int rs = stbi__jpeg_huff_decode(j, hac); + if (rs < 0) return stbi__err("bad huffman code","Corrupt JPEG"); + s = rs & 15; + r = rs >> 4; + if (s == 0) { + if (r < 15) { + j->eob_run = (1 << r); + if (r) + j->eob_run += stbi__jpeg_get_bits(j, r); + --j->eob_run; + break; + } + k += 16; + } else { + k += r; + zig = stbi__jpeg_dezigzag[k++]; + data[zig] = (short) (stbi__extend_receive(j,s) << shift); + } + } + } while (k <= j->spec_end); + } else { + // refinement scan for these AC coefficients + + short bit = (short) (1 << j->succ_low); + + if (j->eob_run) { + --j->eob_run; + for (k = j->spec_start; k <= j->spec_end; ++k) { + short *p = &data[stbi__jpeg_dezigzag[k]]; + if (*p != 0) + if (stbi__jpeg_get_bit(j)) + if ((*p & bit)==0) { + if (*p > 0) + *p += bit; + else + *p -= bit; + } + } + } else { + k = j->spec_start; + do { + int r,s; + int rs = stbi__jpeg_huff_decode(j, hac); // @OPTIMIZE see if we can use the fast path here, advance-by-r is so slow, eh + if (rs < 0) return stbi__err("bad huffman code","Corrupt JPEG"); + s = rs & 15; + r = rs >> 4; + if (s == 0) { + if (r < 15) { + j->eob_run = (1 << r) - 1; + if (r) + j->eob_run += stbi__jpeg_get_bits(j, r); + r = 64; // force end of block + } else { + // r=15 s=0 should write 16 0s, so we just do + // a run of 15 0s and then write s (which is 0), + // so we don't have to do anything special here + } + } else { + if (s != 1) return stbi__err("bad huffman code", "Corrupt JPEG"); + // sign bit + if (stbi__jpeg_get_bit(j)) + s = bit; + else + s = -bit; + } + + // advance by r + while (k <= j->spec_end) { + short *p = &data[stbi__jpeg_dezigzag[k++]]; + if (*p != 0) { + if (stbi__jpeg_get_bit(j)) + if ((*p & bit)==0) { + if (*p > 0) + *p += bit; + else + *p -= bit; + } + } else { + if (r == 0) { + *p = (short) s; + break; + } + --r; + } + } + } while (k <= j->spec_end); + } + } + return 1; +} + +// take a -128..127 value and stbi__clamp it and convert to 0..255 +stbi_inline static stbi_uc stbi__clamp(int x) +{ + // trick to use a single test to catch both cases + if ((unsigned int) x > 255) { + if (x < 0) return 0; + if (x > 255) return 255; + } + return (stbi_uc) x; +} + +#define stbi__f2f(x) ((int) (((x) * 4096 + 0.5))) +#define stbi__fsh(x) ((x) * 4096) + +// derived from jidctint -- DCT_ISLOW +#define STBI__IDCT_1D(s0,s1,s2,s3,s4,s5,s6,s7) \ + int t0,t1,t2,t3,p1,p2,p3,p4,p5,x0,x1,x2,x3; \ + p2 = s2; \ + p3 = s6; \ + p1 = (p2+p3) * stbi__f2f(0.5411961f); \ + t2 = p1 + p3*stbi__f2f(-1.847759065f); \ + t3 = p1 + p2*stbi__f2f( 0.765366865f); \ + p2 = s0; \ + p3 = s4; \ + t0 = stbi__fsh(p2+p3); \ + t1 = stbi__fsh(p2-p3); \ + x0 = t0+t3; \ + x3 = t0-t3; \ + x1 = t1+t2; \ + x2 = t1-t2; \ + t0 = s7; \ + t1 = s5; \ + t2 = s3; \ + t3 = s1; \ + p3 = t0+t2; \ + p4 = t1+t3; \ + p1 = t0+t3; \ + p2 = t1+t2; \ + p5 = (p3+p4)*stbi__f2f( 1.175875602f); \ + t0 = t0*stbi__f2f( 0.298631336f); \ + t1 = t1*stbi__f2f( 2.053119869f); \ + t2 = t2*stbi__f2f( 3.072711026f); \ + t3 = t3*stbi__f2f( 1.501321110f); \ + p1 = p5 + p1*stbi__f2f(-0.899976223f); \ + p2 = p5 + p2*stbi__f2f(-2.562915447f); \ + p3 = p3*stbi__f2f(-1.961570560f); \ + p4 = p4*stbi__f2f(-0.390180644f); \ + t3 += p1+p4; \ + t2 += p2+p3; \ + t1 += p2+p4; \ + t0 += p1+p3; + +static void stbi__idct_block(stbi_uc *out, int out_stride, short data[64]) +{ + int i,val[64],*v=val; + stbi_uc *o; + short *d = data; + + // columns + for (i=0; i < 8; ++i,++d, ++v) { + // if all zeroes, shortcut -- this avoids dequantizing 0s and IDCTing + if (d[ 8]==0 && d[16]==0 && d[24]==0 && d[32]==0 + && d[40]==0 && d[48]==0 && d[56]==0) { + // no shortcut 0 seconds + // (1|2|3|4|5|6|7)==0 0 seconds + // all separate -0.047 seconds + // 1 && 2|3 && 4|5 && 6|7: -0.047 seconds + int dcterm = d[0]*4; + v[0] = v[8] = v[16] = v[24] = v[32] = v[40] = v[48] = v[56] = dcterm; + } else { + STBI__IDCT_1D(d[ 0],d[ 8],d[16],d[24],d[32],d[40],d[48],d[56]) + // constants scaled things up by 1<<12; let's bring them back + // down, but keep 2 extra bits of precision + x0 += 512; x1 += 512; x2 += 512; x3 += 512; + v[ 0] = (x0+t3) >> 10; + v[56] = (x0-t3) >> 10; + v[ 8] = (x1+t2) >> 10; + v[48] = (x1-t2) >> 10; + v[16] = (x2+t1) >> 10; + v[40] = (x2-t1) >> 10; + v[24] = (x3+t0) >> 10; + v[32] = (x3-t0) >> 10; + } + } + + for (i=0, v=val, o=out; i < 8; ++i,v+=8,o+=out_stride) { + // no fast case since the first 1D IDCT spread components out + STBI__IDCT_1D(v[0],v[1],v[2],v[3],v[4],v[5],v[6],v[7]) + // constants scaled things up by 1<<12, plus we had 1<<2 from first + // loop, plus horizontal and vertical each scale by sqrt(8) so together + // we've got an extra 1<<3, so 1<<17 total we need to remove. + // so we want to round that, which means adding 0.5 * 1<<17, + // aka 65536. Also, we'll end up with -128 to 127 that we want + // to encode as 0..255 by adding 128, so we'll add that before the shift + x0 += 65536 + (128<<17); + x1 += 65536 + (128<<17); + x2 += 65536 + (128<<17); + x3 += 65536 + (128<<17); + // tried computing the shifts into temps, or'ing the temps to see + // if any were out of range, but that was slower + o[0] = stbi__clamp((x0+t3) >> 17); + o[7] = stbi__clamp((x0-t3) >> 17); + o[1] = stbi__clamp((x1+t2) >> 17); + o[6] = stbi__clamp((x1-t2) >> 17); + o[2] = stbi__clamp((x2+t1) >> 17); + o[5] = stbi__clamp((x2-t1) >> 17); + o[3] = stbi__clamp((x3+t0) >> 17); + o[4] = stbi__clamp((x3-t0) >> 17); + } +} + +#ifdef STBI_SSE2 +// sse2 integer IDCT. not the fastest possible implementation but it +// produces bit-identical results to the generic C version so it's +// fully "transparent". +static void stbi__idct_simd(stbi_uc *out, int out_stride, short data[64]) +{ + // This is constructed to match our regular (generic) integer IDCT exactly. + __m128i row0, row1, row2, row3, row4, row5, row6, row7; + __m128i tmp; + + // dot product constant: even elems=x, odd elems=y + #define dct_const(x,y) _mm_setr_epi16((x),(y),(x),(y),(x),(y),(x),(y)) + + // out(0) = c0[even]*x + c0[odd]*y (c0, x, y 16-bit, out 32-bit) + // out(1) = c1[even]*x + c1[odd]*y + #define dct_rot(out0,out1, x,y,c0,c1) \ + __m128i c0##lo = _mm_unpacklo_epi16((x),(y)); \ + __m128i c0##hi = _mm_unpackhi_epi16((x),(y)); \ + __m128i out0##_l = _mm_madd_epi16(c0##lo, c0); \ + __m128i out0##_h = _mm_madd_epi16(c0##hi, c0); \ + __m128i out1##_l = _mm_madd_epi16(c0##lo, c1); \ + __m128i out1##_h = _mm_madd_epi16(c0##hi, c1) + + // out = in << 12 (in 16-bit, out 32-bit) + #define dct_widen(out, in) \ + __m128i out##_l = _mm_srai_epi32(_mm_unpacklo_epi16(_mm_setzero_si128(), (in)), 4); \ + __m128i out##_h = _mm_srai_epi32(_mm_unpackhi_epi16(_mm_setzero_si128(), (in)), 4) + + // wide add + #define dct_wadd(out, a, b) \ + __m128i out##_l = _mm_add_epi32(a##_l, b##_l); \ + __m128i out##_h = _mm_add_epi32(a##_h, b##_h) + + // wide sub + #define dct_wsub(out, a, b) \ + __m128i out##_l = _mm_sub_epi32(a##_l, b##_l); \ + __m128i out##_h = _mm_sub_epi32(a##_h, b##_h) + + // butterfly a/b, add bias, then shift by "s" and pack + #define dct_bfly32o(out0, out1, a,b,bias,s) \ + { \ + __m128i abiased_l = _mm_add_epi32(a##_l, bias); \ + __m128i abiased_h = _mm_add_epi32(a##_h, bias); \ + dct_wadd(sum, abiased, b); \ + dct_wsub(dif, abiased, b); \ + out0 = _mm_packs_epi32(_mm_srai_epi32(sum_l, s), _mm_srai_epi32(sum_h, s)); \ + out1 = _mm_packs_epi32(_mm_srai_epi32(dif_l, s), _mm_srai_epi32(dif_h, s)); \ + } + + // 8-bit interleave step (for transposes) + #define dct_interleave8(a, b) \ + tmp = a; \ + a = _mm_unpacklo_epi8(a, b); \ + b = _mm_unpackhi_epi8(tmp, b) + + // 16-bit interleave step (for transposes) + #define dct_interleave16(a, b) \ + tmp = a; \ + a = _mm_unpacklo_epi16(a, b); \ + b = _mm_unpackhi_epi16(tmp, b) + + #define dct_pass(bias,shift) \ + { \ + /* even part */ \ + dct_rot(t2e,t3e, row2,row6, rot0_0,rot0_1); \ + __m128i sum04 = _mm_add_epi16(row0, row4); \ + __m128i dif04 = _mm_sub_epi16(row0, row4); \ + dct_widen(t0e, sum04); \ + dct_widen(t1e, dif04); \ + dct_wadd(x0, t0e, t3e); \ + dct_wsub(x3, t0e, t3e); \ + dct_wadd(x1, t1e, t2e); \ + dct_wsub(x2, t1e, t2e); \ + /* odd part */ \ + dct_rot(y0o,y2o, row7,row3, rot2_0,rot2_1); \ + dct_rot(y1o,y3o, row5,row1, rot3_0,rot3_1); \ + __m128i sum17 = _mm_add_epi16(row1, row7); \ + __m128i sum35 = _mm_add_epi16(row3, row5); \ + dct_rot(y4o,y5o, sum17,sum35, rot1_0,rot1_1); \ + dct_wadd(x4, y0o, y4o); \ + dct_wadd(x5, y1o, y5o); \ + dct_wadd(x6, y2o, y5o); \ + dct_wadd(x7, y3o, y4o); \ + dct_bfly32o(row0,row7, x0,x7,bias,shift); \ + dct_bfly32o(row1,row6, x1,x6,bias,shift); \ + dct_bfly32o(row2,row5, x2,x5,bias,shift); \ + dct_bfly32o(row3,row4, x3,x4,bias,shift); \ + } + + __m128i rot0_0 = dct_const(stbi__f2f(0.5411961f), stbi__f2f(0.5411961f) + stbi__f2f(-1.847759065f)); + __m128i rot0_1 = dct_const(stbi__f2f(0.5411961f) + stbi__f2f( 0.765366865f), stbi__f2f(0.5411961f)); + __m128i rot1_0 = dct_const(stbi__f2f(1.175875602f) + stbi__f2f(-0.899976223f), stbi__f2f(1.175875602f)); + __m128i rot1_1 = dct_const(stbi__f2f(1.175875602f), stbi__f2f(1.175875602f) + stbi__f2f(-2.562915447f)); + __m128i rot2_0 = dct_const(stbi__f2f(-1.961570560f) + stbi__f2f( 0.298631336f), stbi__f2f(-1.961570560f)); + __m128i rot2_1 = dct_const(stbi__f2f(-1.961570560f), stbi__f2f(-1.961570560f) + stbi__f2f( 3.072711026f)); + __m128i rot3_0 = dct_const(stbi__f2f(-0.390180644f) + stbi__f2f( 2.053119869f), stbi__f2f(-0.390180644f)); + __m128i rot3_1 = dct_const(stbi__f2f(-0.390180644f), stbi__f2f(-0.390180644f) + stbi__f2f( 1.501321110f)); + + // rounding biases in column/row passes, see stbi__idct_block for explanation. + __m128i bias_0 = _mm_set1_epi32(512); + __m128i bias_1 = _mm_set1_epi32(65536 + (128<<17)); + + // load + row0 = _mm_load_si128((const __m128i *) (data + 0*8)); + row1 = _mm_load_si128((const __m128i *) (data + 1*8)); + row2 = _mm_load_si128((const __m128i *) (data + 2*8)); + row3 = _mm_load_si128((const __m128i *) (data + 3*8)); + row4 = _mm_load_si128((const __m128i *) (data + 4*8)); + row5 = _mm_load_si128((const __m128i *) (data + 5*8)); + row6 = _mm_load_si128((const __m128i *) (data + 6*8)); + row7 = _mm_load_si128((const __m128i *) (data + 7*8)); + + // column pass + dct_pass(bias_0, 10); + + { + // 16bit 8x8 transpose pass 1 + dct_interleave16(row0, row4); + dct_interleave16(row1, row5); + dct_interleave16(row2, row6); + dct_interleave16(row3, row7); + + // transpose pass 2 + dct_interleave16(row0, row2); + dct_interleave16(row1, row3); + dct_interleave16(row4, row6); + dct_interleave16(row5, row7); + + // transpose pass 3 + dct_interleave16(row0, row1); + dct_interleave16(row2, row3); + dct_interleave16(row4, row5); + dct_interleave16(row6, row7); + } + + // row pass + dct_pass(bias_1, 17); + + { + // pack + __m128i p0 = _mm_packus_epi16(row0, row1); // a0a1a2a3...a7b0b1b2b3...b7 + __m128i p1 = _mm_packus_epi16(row2, row3); + __m128i p2 = _mm_packus_epi16(row4, row5); + __m128i p3 = _mm_packus_epi16(row6, row7); + + // 8bit 8x8 transpose pass 1 + dct_interleave8(p0, p2); // a0e0a1e1... + dct_interleave8(p1, p3); // c0g0c1g1... + + // transpose pass 2 + dct_interleave8(p0, p1); // a0c0e0g0... + dct_interleave8(p2, p3); // b0d0f0h0... + + // transpose pass 3 + dct_interleave8(p0, p2); // a0b0c0d0... + dct_interleave8(p1, p3); // a4b4c4d4... + + // store + _mm_storel_epi64((__m128i *) out, p0); out += out_stride; + _mm_storel_epi64((__m128i *) out, _mm_shuffle_epi32(p0, 0x4e)); out += out_stride; + _mm_storel_epi64((__m128i *) out, p2); out += out_stride; + _mm_storel_epi64((__m128i *) out, _mm_shuffle_epi32(p2, 0x4e)); out += out_stride; + _mm_storel_epi64((__m128i *) out, p1); out += out_stride; + _mm_storel_epi64((__m128i *) out, _mm_shuffle_epi32(p1, 0x4e)); out += out_stride; + _mm_storel_epi64((__m128i *) out, p3); out += out_stride; + _mm_storel_epi64((__m128i *) out, _mm_shuffle_epi32(p3, 0x4e)); + } + +#undef dct_const +#undef dct_rot +#undef dct_widen +#undef dct_wadd +#undef dct_wsub +#undef dct_bfly32o +#undef dct_interleave8 +#undef dct_interleave16 +#undef dct_pass +} + +#endif // STBI_SSE2 + +#ifdef STBI_NEON + +// NEON integer IDCT. should produce bit-identical +// results to the generic C version. +static void stbi__idct_simd(stbi_uc *out, int out_stride, short data[64]) +{ + int16x8_t row0, row1, row2, row3, row4, row5, row6, row7; + + int16x4_t rot0_0 = vdup_n_s16(stbi__f2f(0.5411961f)); + int16x4_t rot0_1 = vdup_n_s16(stbi__f2f(-1.847759065f)); + int16x4_t rot0_2 = vdup_n_s16(stbi__f2f( 0.765366865f)); + int16x4_t rot1_0 = vdup_n_s16(stbi__f2f( 1.175875602f)); + int16x4_t rot1_1 = vdup_n_s16(stbi__f2f(-0.899976223f)); + int16x4_t rot1_2 = vdup_n_s16(stbi__f2f(-2.562915447f)); + int16x4_t rot2_0 = vdup_n_s16(stbi__f2f(-1.961570560f)); + int16x4_t rot2_1 = vdup_n_s16(stbi__f2f(-0.390180644f)); + int16x4_t rot3_0 = vdup_n_s16(stbi__f2f( 0.298631336f)); + int16x4_t rot3_1 = vdup_n_s16(stbi__f2f( 2.053119869f)); + int16x4_t rot3_2 = vdup_n_s16(stbi__f2f( 3.072711026f)); + int16x4_t rot3_3 = vdup_n_s16(stbi__f2f( 1.501321110f)); + +#define dct_long_mul(out, inq, coeff) \ + int32x4_t out##_l = vmull_s16(vget_low_s16(inq), coeff); \ + int32x4_t out##_h = vmull_s16(vget_high_s16(inq), coeff) + +#define dct_long_mac(out, acc, inq, coeff) \ + int32x4_t out##_l = vmlal_s16(acc##_l, vget_low_s16(inq), coeff); \ + int32x4_t out##_h = vmlal_s16(acc##_h, vget_high_s16(inq), coeff) + +#define dct_widen(out, inq) \ + int32x4_t out##_l = vshll_n_s16(vget_low_s16(inq), 12); \ + int32x4_t out##_h = vshll_n_s16(vget_high_s16(inq), 12) + +// wide add +#define dct_wadd(out, a, b) \ + int32x4_t out##_l = vaddq_s32(a##_l, b##_l); \ + int32x4_t out##_h = vaddq_s32(a##_h, b##_h) + +// wide sub +#define dct_wsub(out, a, b) \ + int32x4_t out##_l = vsubq_s32(a##_l, b##_l); \ + int32x4_t out##_h = vsubq_s32(a##_h, b##_h) + +// butterfly a/b, then shift using "shiftop" by "s" and pack +#define dct_bfly32o(out0,out1, a,b,shiftop,s) \ + { \ + dct_wadd(sum, a, b); \ + dct_wsub(dif, a, b); \ + out0 = vcombine_s16(shiftop(sum_l, s), shiftop(sum_h, s)); \ + out1 = vcombine_s16(shiftop(dif_l, s), shiftop(dif_h, s)); \ + } + +#define dct_pass(shiftop, shift) \ + { \ + /* even part */ \ + int16x8_t sum26 = vaddq_s16(row2, row6); \ + dct_long_mul(p1e, sum26, rot0_0); \ + dct_long_mac(t2e, p1e, row6, rot0_1); \ + dct_long_mac(t3e, p1e, row2, rot0_2); \ + int16x8_t sum04 = vaddq_s16(row0, row4); \ + int16x8_t dif04 = vsubq_s16(row0, row4); \ + dct_widen(t0e, sum04); \ + dct_widen(t1e, dif04); \ + dct_wadd(x0, t0e, t3e); \ + dct_wsub(x3, t0e, t3e); \ + dct_wadd(x1, t1e, t2e); \ + dct_wsub(x2, t1e, t2e); \ + /* odd part */ \ + int16x8_t sum15 = vaddq_s16(row1, row5); \ + int16x8_t sum17 = vaddq_s16(row1, row7); \ + int16x8_t sum35 = vaddq_s16(row3, row5); \ + int16x8_t sum37 = vaddq_s16(row3, row7); \ + int16x8_t sumodd = vaddq_s16(sum17, sum35); \ + dct_long_mul(p5o, sumodd, rot1_0); \ + dct_long_mac(p1o, p5o, sum17, rot1_1); \ + dct_long_mac(p2o, p5o, sum35, rot1_2); \ + dct_long_mul(p3o, sum37, rot2_0); \ + dct_long_mul(p4o, sum15, rot2_1); \ + dct_wadd(sump13o, p1o, p3o); \ + dct_wadd(sump24o, p2o, p4o); \ + dct_wadd(sump23o, p2o, p3o); \ + dct_wadd(sump14o, p1o, p4o); \ + dct_long_mac(x4, sump13o, row7, rot3_0); \ + dct_long_mac(x5, sump24o, row5, rot3_1); \ + dct_long_mac(x6, sump23o, row3, rot3_2); \ + dct_long_mac(x7, sump14o, row1, rot3_3); \ + dct_bfly32o(row0,row7, x0,x7,shiftop,shift); \ + dct_bfly32o(row1,row6, x1,x6,shiftop,shift); \ + dct_bfly32o(row2,row5, x2,x5,shiftop,shift); \ + dct_bfly32o(row3,row4, x3,x4,shiftop,shift); \ + } + + // load + row0 = vld1q_s16(data + 0*8); + row1 = vld1q_s16(data + 1*8); + row2 = vld1q_s16(data + 2*8); + row3 = vld1q_s16(data + 3*8); + row4 = vld1q_s16(data + 4*8); + row5 = vld1q_s16(data + 5*8); + row6 = vld1q_s16(data + 6*8); + row7 = vld1q_s16(data + 7*8); + + // add DC bias + row0 = vaddq_s16(row0, vsetq_lane_s16(1024, vdupq_n_s16(0), 0)); + + // column pass + dct_pass(vrshrn_n_s32, 10); + + // 16bit 8x8 transpose + { +// these three map to a single VTRN.16, VTRN.32, and VSWP, respectively. +// whether compilers actually get this is another story, sadly. +#define dct_trn16(x, y) { int16x8x2_t t = vtrnq_s16(x, y); x = t.val[0]; y = t.val[1]; } +#define dct_trn32(x, y) { int32x4x2_t t = vtrnq_s32(vreinterpretq_s32_s16(x), vreinterpretq_s32_s16(y)); x = vreinterpretq_s16_s32(t.val[0]); y = vreinterpretq_s16_s32(t.val[1]); } +#define dct_trn64(x, y) { int16x8_t x0 = x; int16x8_t y0 = y; x = vcombine_s16(vget_low_s16(x0), vget_low_s16(y0)); y = vcombine_s16(vget_high_s16(x0), vget_high_s16(y0)); } + + // pass 1 + dct_trn16(row0, row1); // a0b0a2b2a4b4a6b6 + dct_trn16(row2, row3); + dct_trn16(row4, row5); + dct_trn16(row6, row7); + + // pass 2 + dct_trn32(row0, row2); // a0b0c0d0a4b4c4d4 + dct_trn32(row1, row3); + dct_trn32(row4, row6); + dct_trn32(row5, row7); + + // pass 3 + dct_trn64(row0, row4); // a0b0c0d0e0f0g0h0 + dct_trn64(row1, row5); + dct_trn64(row2, row6); + dct_trn64(row3, row7); + +#undef dct_trn16 +#undef dct_trn32 +#undef dct_trn64 + } + + // row pass + // vrshrn_n_s32 only supports shifts up to 16, we need + // 17. so do a non-rounding shift of 16 first then follow + // up with a rounding shift by 1. + dct_pass(vshrn_n_s32, 16); + + { + // pack and round + uint8x8_t p0 = vqrshrun_n_s16(row0, 1); + uint8x8_t p1 = vqrshrun_n_s16(row1, 1); + uint8x8_t p2 = vqrshrun_n_s16(row2, 1); + uint8x8_t p3 = vqrshrun_n_s16(row3, 1); + uint8x8_t p4 = vqrshrun_n_s16(row4, 1); + uint8x8_t p5 = vqrshrun_n_s16(row5, 1); + uint8x8_t p6 = vqrshrun_n_s16(row6, 1); + uint8x8_t p7 = vqrshrun_n_s16(row7, 1); + + // again, these can translate into one instruction, but often don't. +#define dct_trn8_8(x, y) { uint8x8x2_t t = vtrn_u8(x, y); x = t.val[0]; y = t.val[1]; } +#define dct_trn8_16(x, y) { uint16x4x2_t t = vtrn_u16(vreinterpret_u16_u8(x), vreinterpret_u16_u8(y)); x = vreinterpret_u8_u16(t.val[0]); y = vreinterpret_u8_u16(t.val[1]); } +#define dct_trn8_32(x, y) { uint32x2x2_t t = vtrn_u32(vreinterpret_u32_u8(x), vreinterpret_u32_u8(y)); x = vreinterpret_u8_u32(t.val[0]); y = vreinterpret_u8_u32(t.val[1]); } + + // sadly can't use interleaved stores here since we only write + // 8 bytes to each scan line! + + // 8x8 8-bit transpose pass 1 + dct_trn8_8(p0, p1); + dct_trn8_8(p2, p3); + dct_trn8_8(p4, p5); + dct_trn8_8(p6, p7); + + // pass 2 + dct_trn8_16(p0, p2); + dct_trn8_16(p1, p3); + dct_trn8_16(p4, p6); + dct_trn8_16(p5, p7); + + // pass 3 + dct_trn8_32(p0, p4); + dct_trn8_32(p1, p5); + dct_trn8_32(p2, p6); + dct_trn8_32(p3, p7); + + // store + vst1_u8(out, p0); out += out_stride; + vst1_u8(out, p1); out += out_stride; + vst1_u8(out, p2); out += out_stride; + vst1_u8(out, p3); out += out_stride; + vst1_u8(out, p4); out += out_stride; + vst1_u8(out, p5); out += out_stride; + vst1_u8(out, p6); out += out_stride; + vst1_u8(out, p7); + +#undef dct_trn8_8 +#undef dct_trn8_16 +#undef dct_trn8_32 + } + +#undef dct_long_mul +#undef dct_long_mac +#undef dct_widen +#undef dct_wadd +#undef dct_wsub +#undef dct_bfly32o +#undef dct_pass +} + +#endif // STBI_NEON + +#define STBI__MARKER_none 0xff +// if there's a pending marker from the entropy stream, return that +// otherwise, fetch from the stream and get a marker. if there's no +// marker, return 0xff, which is never a valid marker value +static stbi_uc stbi__get_marker(stbi__jpeg *j) +{ + stbi_uc x; + if (j->marker != STBI__MARKER_none) { x = j->marker; j->marker = STBI__MARKER_none; return x; } + x = stbi__get8(j->s); + if (x != 0xff) return STBI__MARKER_none; + while (x == 0xff) + x = stbi__get8(j->s); // consume repeated 0xff fill bytes + return x; +} + +// in each scan, we'll have scan_n components, and the order +// of the components is specified by order[] +#define STBI__RESTART(x) ((x) >= 0xd0 && (x) <= 0xd7) + +// after a restart interval, stbi__jpeg_reset the entropy decoder and +// the dc prediction +static void stbi__jpeg_reset(stbi__jpeg *j) +{ + j->code_bits = 0; + j->code_buffer = 0; + j->nomore = 0; + j->img_comp[0].dc_pred = j->img_comp[1].dc_pred = j->img_comp[2].dc_pred = j->img_comp[3].dc_pred = 0; + j->marker = STBI__MARKER_none; + j->todo = j->restart_interval ? j->restart_interval : 0x7fffffff; + j->eob_run = 0; + // no more than 1<<31 MCUs if no restart_interal? that's plenty safe, + // since we don't even allow 1<<30 pixels +} + +static int stbi__parse_entropy_coded_data(stbi__jpeg *z) +{ + stbi__jpeg_reset(z); + if (!z->progressive) { + if (z->scan_n == 1) { + int i,j; + STBI_SIMD_ALIGN(short, data[64]); + int n = z->order[0]; + // non-interleaved data, we just need to process one block at a time, + // in trivial scanline order + // number of blocks to do just depends on how many actual "pixels" this + // component has, independent of interleaved MCU blocking and such + int w = (z->img_comp[n].x+7) >> 3; + int h = (z->img_comp[n].y+7) >> 3; + for (j=0; j < h; ++j) { + for (i=0; i < w; ++i) { + int ha = z->img_comp[n].ha; + if (!stbi__jpeg_decode_block(z, data, z->huff_dc+z->img_comp[n].hd, z->huff_ac+ha, z->fast_ac[ha], n, z->dequant[z->img_comp[n].tq])) return 0; + z->idct_block_kernel(z->img_comp[n].data+z->img_comp[n].w2*j*8+i*8, z->img_comp[n].w2, data); + // every data block is an MCU, so countdown the restart interval + if (--z->todo <= 0) { + if (z->code_bits < 24) stbi__grow_buffer_unsafe(z); + // if it's NOT a restart, then just bail, so we get corrupt data + // rather than no data + if (!STBI__RESTART(z->marker)) return 1; + stbi__jpeg_reset(z); + } + } + } + return 1; + } else { // interleaved + int i,j,k,x,y; + STBI_SIMD_ALIGN(short, data[64]); + for (j=0; j < z->img_mcu_y; ++j) { + for (i=0; i < z->img_mcu_x; ++i) { + // scan an interleaved mcu... process scan_n components in order + for (k=0; k < z->scan_n; ++k) { + int n = z->order[k]; + // scan out an mcu's worth of this component; that's just determined + // by the basic H and V specified for the component + for (y=0; y < z->img_comp[n].v; ++y) { + for (x=0; x < z->img_comp[n].h; ++x) { + int x2 = (i*z->img_comp[n].h + x)*8; + int y2 = (j*z->img_comp[n].v + y)*8; + int ha = z->img_comp[n].ha; + if (!stbi__jpeg_decode_block(z, data, z->huff_dc+z->img_comp[n].hd, z->huff_ac+ha, z->fast_ac[ha], n, z->dequant[z->img_comp[n].tq])) return 0; + z->idct_block_kernel(z->img_comp[n].data+z->img_comp[n].w2*y2+x2, z->img_comp[n].w2, data); + } + } + } + // after all interleaved components, that's an interleaved MCU, + // so now count down the restart interval + if (--z->todo <= 0) { + if (z->code_bits < 24) stbi__grow_buffer_unsafe(z); + if (!STBI__RESTART(z->marker)) return 1; + stbi__jpeg_reset(z); + } + } + } + return 1; + } + } else { + if (z->scan_n == 1) { + int i,j; + int n = z->order[0]; + // non-interleaved data, we just need to process one block at a time, + // in trivial scanline order + // number of blocks to do just depends on how many actual "pixels" this + // component has, independent of interleaved MCU blocking and such + int w = (z->img_comp[n].x+7) >> 3; + int h = (z->img_comp[n].y+7) >> 3; + for (j=0; j < h; ++j) { + for (i=0; i < w; ++i) { + short *data = z->img_comp[n].coeff + 64 * (i + j * z->img_comp[n].coeff_w); + if (z->spec_start == 0) { + if (!stbi__jpeg_decode_block_prog_dc(z, data, &z->huff_dc[z->img_comp[n].hd], n)) + return 0; + } else { + int ha = z->img_comp[n].ha; + if (!stbi__jpeg_decode_block_prog_ac(z, data, &z->huff_ac[ha], z->fast_ac[ha])) + return 0; + } + // every data block is an MCU, so countdown the restart interval + if (--z->todo <= 0) { + if (z->code_bits < 24) stbi__grow_buffer_unsafe(z); + if (!STBI__RESTART(z->marker)) return 1; + stbi__jpeg_reset(z); + } + } + } + return 1; + } else { // interleaved + int i,j,k,x,y; + for (j=0; j < z->img_mcu_y; ++j) { + for (i=0; i < z->img_mcu_x; ++i) { + // scan an interleaved mcu... process scan_n components in order + for (k=0; k < z->scan_n; ++k) { + int n = z->order[k]; + // scan out an mcu's worth of this component; that's just determined + // by the basic H and V specified for the component + for (y=0; y < z->img_comp[n].v; ++y) { + for (x=0; x < z->img_comp[n].h; ++x) { + int x2 = (i*z->img_comp[n].h + x); + int y2 = (j*z->img_comp[n].v + y); + short *data = z->img_comp[n].coeff + 64 * (x2 + y2 * z->img_comp[n].coeff_w); + if (!stbi__jpeg_decode_block_prog_dc(z, data, &z->huff_dc[z->img_comp[n].hd], n)) + return 0; + } + } + } + // after all interleaved components, that's an interleaved MCU, + // so now count down the restart interval + if (--z->todo <= 0) { + if (z->code_bits < 24) stbi__grow_buffer_unsafe(z); + if (!STBI__RESTART(z->marker)) return 1; + stbi__jpeg_reset(z); + } + } + } + return 1; + } + } +} + +static void stbi__jpeg_dequantize(short *data, stbi__uint16 *dequant) +{ + int i; + for (i=0; i < 64; ++i) + data[i] *= dequant[i]; +} + +static void stbi__jpeg_finish(stbi__jpeg *z) +{ + if (z->progressive) { + // dequantize and idct the data + int i,j,n; + for (n=0; n < z->s->img_n; ++n) { + int w = (z->img_comp[n].x+7) >> 3; + int h = (z->img_comp[n].y+7) >> 3; + for (j=0; j < h; ++j) { + for (i=0; i < w; ++i) { + short *data = z->img_comp[n].coeff + 64 * (i + j * z->img_comp[n].coeff_w); + stbi__jpeg_dequantize(data, z->dequant[z->img_comp[n].tq]); + z->idct_block_kernel(z->img_comp[n].data+z->img_comp[n].w2*j*8+i*8, z->img_comp[n].w2, data); + } + } + } + } +} + +static int stbi__process_marker(stbi__jpeg *z, int m) +{ + int L; + switch (m) { + case STBI__MARKER_none: // no marker found + return stbi__err("expected marker","Corrupt JPEG"); + + case 0xDD: // DRI - specify restart interval + if (stbi__get16be(z->s) != 4) return stbi__err("bad DRI len","Corrupt JPEG"); + z->restart_interval = stbi__get16be(z->s); + return 1; + + case 0xDB: // DQT - define quantization table + L = stbi__get16be(z->s)-2; + while (L > 0) { + int q = stbi__get8(z->s); + int p = q >> 4, sixteen = (p != 0); + int t = q & 15,i; + if (p != 0 && p != 1) return stbi__err("bad DQT type","Corrupt JPEG"); + if (t > 3) return stbi__err("bad DQT table","Corrupt JPEG"); + + for (i=0; i < 64; ++i) + z->dequant[t][stbi__jpeg_dezigzag[i]] = (stbi__uint16)(sixteen ? stbi__get16be(z->s) : stbi__get8(z->s)); + L -= (sixteen ? 129 : 65); + } + return L==0; + + case 0xC4: // DHT - define huffman table + L = stbi__get16be(z->s)-2; + while (L > 0) { + stbi_uc *v; + int sizes[16],i,n=0; + int q = stbi__get8(z->s); + int tc = q >> 4; + int th = q & 15; + if (tc > 1 || th > 3) return stbi__err("bad DHT header","Corrupt JPEG"); + for (i=0; i < 16; ++i) { + sizes[i] = stbi__get8(z->s); + n += sizes[i]; + } + L -= 17; + if (tc == 0) { + if (!stbi__build_huffman(z->huff_dc+th, sizes)) return 0; + v = z->huff_dc[th].values; + } else { + if (!stbi__build_huffman(z->huff_ac+th, sizes)) return 0; + v = z->huff_ac[th].values; + } + for (i=0; i < n; ++i) + v[i] = stbi__get8(z->s); + if (tc != 0) + stbi__build_fast_ac(z->fast_ac[th], z->huff_ac + th); + L -= n; + } + return L==0; + } + + // check for comment block or APP blocks + if ((m >= 0xE0 && m <= 0xEF) || m == 0xFE) { + L = stbi__get16be(z->s); + if (L < 2) { + if (m == 0xFE) + return stbi__err("bad COM len","Corrupt JPEG"); + else + return stbi__err("bad APP len","Corrupt JPEG"); + } + L -= 2; + + if (m == 0xE0 && L >= 5) { // JFIF APP0 segment + static const unsigned char tag[5] = {'J','F','I','F','\0'}; + int ok = 1; + int i; + for (i=0; i < 5; ++i) + if (stbi__get8(z->s) != tag[i]) + ok = 0; + L -= 5; + if (ok) + z->jfif = 1; + } else if (m == 0xEE && L >= 12) { // Adobe APP14 segment + static const unsigned char tag[6] = {'A','d','o','b','e','\0'}; + int ok = 1; + int i; + for (i=0; i < 6; ++i) + if (stbi__get8(z->s) != tag[i]) + ok = 0; + L -= 6; + if (ok) { + stbi__get8(z->s); // version + stbi__get16be(z->s); // flags0 + stbi__get16be(z->s); // flags1 + z->app14_color_transform = stbi__get8(z->s); // color transform + L -= 6; + } + } + + stbi__skip(z->s, L); + return 1; + } + + return stbi__err("unknown marker","Corrupt JPEG"); +} + +// after we see SOS +static int stbi__process_scan_header(stbi__jpeg *z) +{ + int i; + int Ls = stbi__get16be(z->s); + z->scan_n = stbi__get8(z->s); + if (z->scan_n < 1 || z->scan_n > 4 || z->scan_n > (int) z->s->img_n) return stbi__err("bad SOS component count","Corrupt JPEG"); + if (Ls != 6+2*z->scan_n) return stbi__err("bad SOS len","Corrupt JPEG"); + for (i=0; i < z->scan_n; ++i) { + int id = stbi__get8(z->s), which; + int q = stbi__get8(z->s); + for (which = 0; which < z->s->img_n; ++which) + if (z->img_comp[which].id == id) + break; + if (which == z->s->img_n) return 0; // no match + z->img_comp[which].hd = q >> 4; if (z->img_comp[which].hd > 3) return stbi__err("bad DC huff","Corrupt JPEG"); + z->img_comp[which].ha = q & 15; if (z->img_comp[which].ha > 3) return stbi__err("bad AC huff","Corrupt JPEG"); + z->order[i] = which; + } + + { + int aa; + z->spec_start = stbi__get8(z->s); + z->spec_end = stbi__get8(z->s); // should be 63, but might be 0 + aa = stbi__get8(z->s); + z->succ_high = (aa >> 4); + z->succ_low = (aa & 15); + if (z->progressive) { + if (z->spec_start > 63 || z->spec_end > 63 || z->spec_start > z->spec_end || z->succ_high > 13 || z->succ_low > 13) + return stbi__err("bad SOS", "Corrupt JPEG"); + } else { + if (z->spec_start != 0) return stbi__err("bad SOS","Corrupt JPEG"); + if (z->succ_high != 0 || z->succ_low != 0) return stbi__err("bad SOS","Corrupt JPEG"); + z->spec_end = 63; + } + } + + return 1; +} + +static int stbi__free_jpeg_components(stbi__jpeg *z, int ncomp, int why) +{ + int i; + for (i=0; i < ncomp; ++i) { + if (z->img_comp[i].raw_data) { + STBI_FREE(z->img_comp[i].raw_data); + z->img_comp[i].raw_data = NULL; + z->img_comp[i].data = NULL; + } + if (z->img_comp[i].raw_coeff) { + STBI_FREE(z->img_comp[i].raw_coeff); + z->img_comp[i].raw_coeff = 0; + z->img_comp[i].coeff = 0; + } + if (z->img_comp[i].linebuf) { + STBI_FREE(z->img_comp[i].linebuf); + z->img_comp[i].linebuf = NULL; + } + } + return why; +} + +static int stbi__process_frame_header(stbi__jpeg *z, int scan) +{ + stbi__context *s = z->s; + int Lf,p,i,q, h_max=1,v_max=1,c; + Lf = stbi__get16be(s); if (Lf < 11) return stbi__err("bad SOF len","Corrupt JPEG"); // JPEG + p = stbi__get8(s); if (p != 8) return stbi__err("only 8-bit","JPEG format not supported: 8-bit only"); // JPEG baseline + s->img_y = stbi__get16be(s); if (s->img_y == 0) return stbi__err("no header height", "JPEG format not supported: delayed height"); // Legal, but we don't handle it--but neither does IJG + s->img_x = stbi__get16be(s); if (s->img_x == 0) return stbi__err("0 width","Corrupt JPEG"); // JPEG requires + if (s->img_y > STBI_MAX_DIMENSIONS) return stbi__err("too large","Very large image (corrupt?)"); + if (s->img_x > STBI_MAX_DIMENSIONS) return stbi__err("too large","Very large image (corrupt?)"); + c = stbi__get8(s); + if (c != 3 && c != 1 && c != 4) return stbi__err("bad component count","Corrupt JPEG"); + s->img_n = c; + for (i=0; i < c; ++i) { + z->img_comp[i].data = NULL; + z->img_comp[i].linebuf = NULL; + } + + if (Lf != 8+3*s->img_n) return stbi__err("bad SOF len","Corrupt JPEG"); + + z->rgb = 0; + for (i=0; i < s->img_n; ++i) { + static const unsigned char rgb[3] = { 'R', 'G', 'B' }; + z->img_comp[i].id = stbi__get8(s); + if (s->img_n == 3 && z->img_comp[i].id == rgb[i]) + ++z->rgb; + q = stbi__get8(s); + z->img_comp[i].h = (q >> 4); if (!z->img_comp[i].h || z->img_comp[i].h > 4) return stbi__err("bad H","Corrupt JPEG"); + z->img_comp[i].v = q & 15; if (!z->img_comp[i].v || z->img_comp[i].v > 4) return stbi__err("bad V","Corrupt JPEG"); + z->img_comp[i].tq = stbi__get8(s); if (z->img_comp[i].tq > 3) return stbi__err("bad TQ","Corrupt JPEG"); + } + + if (scan != STBI__SCAN_load) return 1; + + if (!stbi__mad3sizes_valid(s->img_x, s->img_y, s->img_n, 0)) return stbi__err("too large", "Image too large to decode"); + + for (i=0; i < s->img_n; ++i) { + if (z->img_comp[i].h > h_max) h_max = z->img_comp[i].h; + if (z->img_comp[i].v > v_max) v_max = z->img_comp[i].v; + } + + // compute interleaved mcu info + z->img_h_max = h_max; + z->img_v_max = v_max; + z->img_mcu_w = h_max * 8; + z->img_mcu_h = v_max * 8; + // these sizes can't be more than 17 bits + z->img_mcu_x = (s->img_x + z->img_mcu_w-1) / z->img_mcu_w; + z->img_mcu_y = (s->img_y + z->img_mcu_h-1) / z->img_mcu_h; + + for (i=0; i < s->img_n; ++i) { + // number of effective pixels (e.g. for non-interleaved MCU) + z->img_comp[i].x = (s->img_x * z->img_comp[i].h + h_max-1) / h_max; + z->img_comp[i].y = (s->img_y * z->img_comp[i].v + v_max-1) / v_max; + // to simplify generation, we'll allocate enough memory to decode + // the bogus oversized data from using interleaved MCUs and their + // big blocks (e.g. a 16x16 iMCU on an image of width 33); we won't + // discard the extra data until colorspace conversion + // + // img_mcu_x, img_mcu_y: <=17 bits; comp[i].h and .v are <=4 (checked earlier) + // so these muls can't overflow with 32-bit ints (which we require) + z->img_comp[i].w2 = z->img_mcu_x * z->img_comp[i].h * 8; + z->img_comp[i].h2 = z->img_mcu_y * z->img_comp[i].v * 8; + z->img_comp[i].coeff = 0; + z->img_comp[i].raw_coeff = 0; + z->img_comp[i].linebuf = NULL; + z->img_comp[i].raw_data = stbi__malloc_mad2(z->img_comp[i].w2, z->img_comp[i].h2, 15); + if (z->img_comp[i].raw_data == NULL) + return stbi__free_jpeg_components(z, i+1, stbi__err("outofmem", "Out of memory")); + // align blocks for idct using mmx/sse + z->img_comp[i].data = (stbi_uc*) (((size_t) z->img_comp[i].raw_data + 15) & ~15); + if (z->progressive) { + // w2, h2 are multiples of 8 (see above) + z->img_comp[i].coeff_w = z->img_comp[i].w2 / 8; + z->img_comp[i].coeff_h = z->img_comp[i].h2 / 8; + z->img_comp[i].raw_coeff = stbi__malloc_mad3(z->img_comp[i].w2, z->img_comp[i].h2, sizeof(short), 15); + if (z->img_comp[i].raw_coeff == NULL) + return stbi__free_jpeg_components(z, i+1, stbi__err("outofmem", "Out of memory")); + z->img_comp[i].coeff = (short*) (((size_t) z->img_comp[i].raw_coeff + 15) & ~15); + } + } + + return 1; +} + +// use comparisons since in some cases we handle more than one case (e.g. SOF) +#define stbi__DNL(x) ((x) == 0xdc) +#define stbi__SOI(x) ((x) == 0xd8) +#define stbi__EOI(x) ((x) == 0xd9) +#define stbi__SOF(x) ((x) == 0xc0 || (x) == 0xc1 || (x) == 0xc2) +#define stbi__SOS(x) ((x) == 0xda) + +#define stbi__SOF_progressive(x) ((x) == 0xc2) + +static int stbi__decode_jpeg_header(stbi__jpeg *z, int scan) +{ + int m; + z->jfif = 0; + z->app14_color_transform = -1; // valid values are 0,1,2 + z->marker = STBI__MARKER_none; // initialize cached marker to empty + m = stbi__get_marker(z); + if (!stbi__SOI(m)) return stbi__err("no SOI","Corrupt JPEG"); + if (scan == STBI__SCAN_type) return 1; + m = stbi__get_marker(z); + while (!stbi__SOF(m)) { + if (!stbi__process_marker(z,m)) return 0; + m = stbi__get_marker(z); + while (m == STBI__MARKER_none) { + // some files have extra padding after their blocks, so ok, we'll scan + if (stbi__at_eof(z->s)) return stbi__err("no SOF", "Corrupt JPEG"); + m = stbi__get_marker(z); + } + } + z->progressive = stbi__SOF_progressive(m); + if (!stbi__process_frame_header(z, scan)) return 0; + return 1; +} + +// decode image to YCbCr format +static int stbi__decode_jpeg_image(stbi__jpeg *j) +{ + int m; + for (m = 0; m < 4; m++) { + j->img_comp[m].raw_data = NULL; + j->img_comp[m].raw_coeff = NULL; + } + j->restart_interval = 0; + if (!stbi__decode_jpeg_header(j, STBI__SCAN_load)) return 0; + m = stbi__get_marker(j); + while (!stbi__EOI(m)) { + if (stbi__SOS(m)) { + if (!stbi__process_scan_header(j)) return 0; + if (!stbi__parse_entropy_coded_data(j)) return 0; + if (j->marker == STBI__MARKER_none ) { + // handle 0s at the end of image data from IP Kamera 9060 + while (!stbi__at_eof(j->s)) { + int x = stbi__get8(j->s); + if (x == 255) { + j->marker = stbi__get8(j->s); + break; + } + } + // if we reach eof without hitting a marker, stbi__get_marker() below will fail and we'll eventually return 0 + } + } else if (stbi__DNL(m)) { + int Ld = stbi__get16be(j->s); + stbi__uint32 NL = stbi__get16be(j->s); + if (Ld != 4) return stbi__err("bad DNL len", "Corrupt JPEG"); + if (NL != j->s->img_y) return stbi__err("bad DNL height", "Corrupt JPEG"); + } else { + if (!stbi__process_marker(j, m)) return 0; + } + m = stbi__get_marker(j); + } + if (j->progressive) + stbi__jpeg_finish(j); + return 1; +} + +// static jfif-centered resampling (across block boundaries) + +typedef stbi_uc *(*resample_row_func)(stbi_uc *out, stbi_uc *in0, stbi_uc *in1, + int w, int hs); + +#define stbi__div4(x) ((stbi_uc) ((x) >> 2)) + +static stbi_uc *resample_row_1(stbi_uc *out, stbi_uc *in_near, stbi_uc *in_far, int w, int hs) +{ + STBI_NOTUSED(out); + STBI_NOTUSED(in_far); + STBI_NOTUSED(w); + STBI_NOTUSED(hs); + return in_near; +} + +static stbi_uc* stbi__resample_row_v_2(stbi_uc *out, stbi_uc *in_near, stbi_uc *in_far, int w, int hs) +{ + // need to generate two samples vertically for every one in input + int i; + STBI_NOTUSED(hs); + for (i=0; i < w; ++i) + out[i] = stbi__div4(3*in_near[i] + in_far[i] + 2); + return out; +} + +static stbi_uc* stbi__resample_row_h_2(stbi_uc *out, stbi_uc *in_near, stbi_uc *in_far, int w, int hs) +{ + // need to generate two samples horizontally for every one in input + int i; + stbi_uc *input = in_near; + + if (w == 1) { + // if only one sample, can't do any interpolation + out[0] = out[1] = input[0]; + return out; + } + + out[0] = input[0]; + out[1] = stbi__div4(input[0]*3 + input[1] + 2); + for (i=1; i < w-1; ++i) { + int n = 3*input[i]+2; + out[i*2+0] = stbi__div4(n+input[i-1]); + out[i*2+1] = stbi__div4(n+input[i+1]); + } + out[i*2+0] = stbi__div4(input[w-2]*3 + input[w-1] + 2); + out[i*2+1] = input[w-1]; + + STBI_NOTUSED(in_far); + STBI_NOTUSED(hs); + + return out; +} + +#define stbi__div16(x) ((stbi_uc) ((x) >> 4)) + +static stbi_uc *stbi__resample_row_hv_2(stbi_uc *out, stbi_uc *in_near, stbi_uc *in_far, int w, int hs) +{ + // need to generate 2x2 samples for every one in input + int i,t0,t1; + if (w == 1) { + out[0] = out[1] = stbi__div4(3*in_near[0] + in_far[0] + 2); + return out; + } + + t1 = 3*in_near[0] + in_far[0]; + out[0] = stbi__div4(t1+2); + for (i=1; i < w; ++i) { + t0 = t1; + t1 = 3*in_near[i]+in_far[i]; + out[i*2-1] = stbi__div16(3*t0 + t1 + 8); + out[i*2 ] = stbi__div16(3*t1 + t0 + 8); + } + out[w*2-1] = stbi__div4(t1+2); + + STBI_NOTUSED(hs); + + return out; +} + +#if defined(STBI_SSE2) || defined(STBI_NEON) +static stbi_uc *stbi__resample_row_hv_2_simd(stbi_uc *out, stbi_uc *in_near, stbi_uc *in_far, int w, int hs) +{ + // need to generate 2x2 samples for every one in input + int i=0,t0,t1; + + if (w == 1) { + out[0] = out[1] = stbi__div4(3*in_near[0] + in_far[0] + 2); + return out; + } + + t1 = 3*in_near[0] + in_far[0]; + // process groups of 8 pixels for as long as we can. + // note we can't handle the last pixel in a row in this loop + // because we need to handle the filter boundary conditions. + for (; i < ((w-1) & ~7); i += 8) { +#if defined(STBI_SSE2) + // load and perform the vertical filtering pass + // this uses 3*x + y = 4*x + (y - x) + __m128i zero = _mm_setzero_si128(); + __m128i farb = _mm_loadl_epi64((__m128i *) (in_far + i)); + __m128i nearb = _mm_loadl_epi64((__m128i *) (in_near + i)); + __m128i farw = _mm_unpacklo_epi8(farb, zero); + __m128i nearw = _mm_unpacklo_epi8(nearb, zero); + __m128i diff = _mm_sub_epi16(farw, nearw); + __m128i nears = _mm_slli_epi16(nearw, 2); + __m128i curr = _mm_add_epi16(nears, diff); // current row + + // horizontal filter works the same based on shifted vers of current + // row. "prev" is current row shifted right by 1 pixel; we need to + // insert the previous pixel value (from t1). + // "next" is current row shifted left by 1 pixel, with first pixel + // of next block of 8 pixels added in. + __m128i prv0 = _mm_slli_si128(curr, 2); + __m128i nxt0 = _mm_srli_si128(curr, 2); + __m128i prev = _mm_insert_epi16(prv0, t1, 0); + __m128i next = _mm_insert_epi16(nxt0, 3*in_near[i+8] + in_far[i+8], 7); + + // horizontal filter, polyphase implementation since it's convenient: + // even pixels = 3*cur + prev = cur*4 + (prev - cur) + // odd pixels = 3*cur + next = cur*4 + (next - cur) + // note the shared term. + __m128i bias = _mm_set1_epi16(8); + __m128i curs = _mm_slli_epi16(curr, 2); + __m128i prvd = _mm_sub_epi16(prev, curr); + __m128i nxtd = _mm_sub_epi16(next, curr); + __m128i curb = _mm_add_epi16(curs, bias); + __m128i even = _mm_add_epi16(prvd, curb); + __m128i odd = _mm_add_epi16(nxtd, curb); + + // interleave even and odd pixels, then undo scaling. + __m128i int0 = _mm_unpacklo_epi16(even, odd); + __m128i int1 = _mm_unpackhi_epi16(even, odd); + __m128i de0 = _mm_srli_epi16(int0, 4); + __m128i de1 = _mm_srli_epi16(int1, 4); + + // pack and write output + __m128i outv = _mm_packus_epi16(de0, de1); + _mm_storeu_si128((__m128i *) (out + i*2), outv); +#elif defined(STBI_NEON) + // load and perform the vertical filtering pass + // this uses 3*x + y = 4*x + (y - x) + uint8x8_t farb = vld1_u8(in_far + i); + uint8x8_t nearb = vld1_u8(in_near + i); + int16x8_t diff = vreinterpretq_s16_u16(vsubl_u8(farb, nearb)); + int16x8_t nears = vreinterpretq_s16_u16(vshll_n_u8(nearb, 2)); + int16x8_t curr = vaddq_s16(nears, diff); // current row + + // horizontal filter works the same based on shifted vers of current + // row. "prev" is current row shifted right by 1 pixel; we need to + // insert the previous pixel value (from t1). + // "next" is current row shifted left by 1 pixel, with first pixel + // of next block of 8 pixels added in. + int16x8_t prv0 = vextq_s16(curr, curr, 7); + int16x8_t nxt0 = vextq_s16(curr, curr, 1); + int16x8_t prev = vsetq_lane_s16(t1, prv0, 0); + int16x8_t next = vsetq_lane_s16(3*in_near[i+8] + in_far[i+8], nxt0, 7); + + // horizontal filter, polyphase implementation since it's convenient: + // even pixels = 3*cur + prev = cur*4 + (prev - cur) + // odd pixels = 3*cur + next = cur*4 + (next - cur) + // note the shared term. + int16x8_t curs = vshlq_n_s16(curr, 2); + int16x8_t prvd = vsubq_s16(prev, curr); + int16x8_t nxtd = vsubq_s16(next, curr); + int16x8_t even = vaddq_s16(curs, prvd); + int16x8_t odd = vaddq_s16(curs, nxtd); + + // undo scaling and round, then store with even/odd phases interleaved + uint8x8x2_t o; + o.val[0] = vqrshrun_n_s16(even, 4); + o.val[1] = vqrshrun_n_s16(odd, 4); + vst2_u8(out + i*2, o); +#endif + + // "previous" value for next iter + t1 = 3*in_near[i+7] + in_far[i+7]; + } + + t0 = t1; + t1 = 3*in_near[i] + in_far[i]; + out[i*2] = stbi__div16(3*t1 + t0 + 8); + + for (++i; i < w; ++i) { + t0 = t1; + t1 = 3*in_near[i]+in_far[i]; + out[i*2-1] = stbi__div16(3*t0 + t1 + 8); + out[i*2 ] = stbi__div16(3*t1 + t0 + 8); + } + out[w*2-1] = stbi__div4(t1+2); + + STBI_NOTUSED(hs); + + return out; +} +#endif + +static stbi_uc *stbi__resample_row_generic(stbi_uc *out, stbi_uc *in_near, stbi_uc *in_far, int w, int hs) +{ + // resample with nearest-neighbor + int i,j; + STBI_NOTUSED(in_far); + for (i=0; i < w; ++i) + for (j=0; j < hs; ++j) + out[i*hs+j] = in_near[i]; + return out; +} + +// this is a reduced-precision calculation of YCbCr-to-RGB introduced +// to make sure the code produces the same results in both SIMD and scalar +#define stbi__float2fixed(x) (((int) ((x) * 4096.0f + 0.5f)) << 8) +static void stbi__YCbCr_to_RGB_row(stbi_uc *out, const stbi_uc *y, const stbi_uc *pcb, const stbi_uc *pcr, int count, int step) +{ + int i; + for (i=0; i < count; ++i) { + int y_fixed = (y[i] << 20) + (1<<19); // rounding + int r,g,b; + int cr = pcr[i] - 128; + int cb = pcb[i] - 128; + r = y_fixed + cr* stbi__float2fixed(1.40200f); + g = y_fixed + (cr*-stbi__float2fixed(0.71414f)) + ((cb*-stbi__float2fixed(0.34414f)) & 0xffff0000); + b = y_fixed + cb* stbi__float2fixed(1.77200f); + r >>= 20; + g >>= 20; + b >>= 20; + if ((unsigned) r > 255) { if (r < 0) r = 0; else r = 255; } + if ((unsigned) g > 255) { if (g < 0) g = 0; else g = 255; } + if ((unsigned) b > 255) { if (b < 0) b = 0; else b = 255; } + out[0] = (stbi_uc)r; + out[1] = (stbi_uc)g; + out[2] = (stbi_uc)b; + out[3] = 255; + out += step; + } +} + +#if defined(STBI_SSE2) || defined(STBI_NEON) +static void stbi__YCbCr_to_RGB_simd(stbi_uc *out, stbi_uc const *y, stbi_uc const *pcb, stbi_uc const *pcr, int count, int step) +{ + int i = 0; + +#ifdef STBI_SSE2 + // step == 3 is pretty ugly on the final interleave, and i'm not convinced + // it's useful in practice (you wouldn't use it for textures, for example). + // so just accelerate step == 4 case. + if (step == 4) { + // this is a fairly straightforward implementation and not super-optimized. + __m128i signflip = _mm_set1_epi8(-0x80); + __m128i cr_const0 = _mm_set1_epi16( (short) ( 1.40200f*4096.0f+0.5f)); + __m128i cr_const1 = _mm_set1_epi16( - (short) ( 0.71414f*4096.0f+0.5f)); + __m128i cb_const0 = _mm_set1_epi16( - (short) ( 0.34414f*4096.0f+0.5f)); + __m128i cb_const1 = _mm_set1_epi16( (short) ( 1.77200f*4096.0f+0.5f)); + __m128i y_bias = _mm_set1_epi8((char) (unsigned char) 128); + __m128i xw = _mm_set1_epi16(255); // alpha channel + + for (; i+7 < count; i += 8) { + // load + __m128i y_bytes = _mm_loadl_epi64((__m128i *) (y+i)); + __m128i cr_bytes = _mm_loadl_epi64((__m128i *) (pcr+i)); + __m128i cb_bytes = _mm_loadl_epi64((__m128i *) (pcb+i)); + __m128i cr_biased = _mm_xor_si128(cr_bytes, signflip); // -128 + __m128i cb_biased = _mm_xor_si128(cb_bytes, signflip); // -128 + + // unpack to short (and left-shift cr, cb by 8) + __m128i yw = _mm_unpacklo_epi8(y_bias, y_bytes); + __m128i crw = _mm_unpacklo_epi8(_mm_setzero_si128(), cr_biased); + __m128i cbw = _mm_unpacklo_epi8(_mm_setzero_si128(), cb_biased); + + // color transform + __m128i yws = _mm_srli_epi16(yw, 4); + __m128i cr0 = _mm_mulhi_epi16(cr_const0, crw); + __m128i cb0 = _mm_mulhi_epi16(cb_const0, cbw); + __m128i cb1 = _mm_mulhi_epi16(cbw, cb_const1); + __m128i cr1 = _mm_mulhi_epi16(crw, cr_const1); + __m128i rws = _mm_add_epi16(cr0, yws); + __m128i gwt = _mm_add_epi16(cb0, yws); + __m128i bws = _mm_add_epi16(yws, cb1); + __m128i gws = _mm_add_epi16(gwt, cr1); + + // descale + __m128i rw = _mm_srai_epi16(rws, 4); + __m128i bw = _mm_srai_epi16(bws, 4); + __m128i gw = _mm_srai_epi16(gws, 4); + + // back to byte, set up for transpose + __m128i brb = _mm_packus_epi16(rw, bw); + __m128i gxb = _mm_packus_epi16(gw, xw); + + // transpose to interleave channels + __m128i t0 = _mm_unpacklo_epi8(brb, gxb); + __m128i t1 = _mm_unpackhi_epi8(brb, gxb); + __m128i o0 = _mm_unpacklo_epi16(t0, t1); + __m128i o1 = _mm_unpackhi_epi16(t0, t1); + + // store + _mm_storeu_si128((__m128i *) (out + 0), o0); + _mm_storeu_si128((__m128i *) (out + 16), o1); + out += 32; + } + } +#endif + +#ifdef STBI_NEON + // in this version, step=3 support would be easy to add. but is there demand? + if (step == 4) { + // this is a fairly straightforward implementation and not super-optimized. + uint8x8_t signflip = vdup_n_u8(0x80); + int16x8_t cr_const0 = vdupq_n_s16( (short) ( 1.40200f*4096.0f+0.5f)); + int16x8_t cr_const1 = vdupq_n_s16( - (short) ( 0.71414f*4096.0f+0.5f)); + int16x8_t cb_const0 = vdupq_n_s16( - (short) ( 0.34414f*4096.0f+0.5f)); + int16x8_t cb_const1 = vdupq_n_s16( (short) ( 1.77200f*4096.0f+0.5f)); + + for (; i+7 < count; i += 8) { + // load + uint8x8_t y_bytes = vld1_u8(y + i); + uint8x8_t cr_bytes = vld1_u8(pcr + i); + uint8x8_t cb_bytes = vld1_u8(pcb + i); + int8x8_t cr_biased = vreinterpret_s8_u8(vsub_u8(cr_bytes, signflip)); + int8x8_t cb_biased = vreinterpret_s8_u8(vsub_u8(cb_bytes, signflip)); + + // expand to s16 + int16x8_t yws = vreinterpretq_s16_u16(vshll_n_u8(y_bytes, 4)); + int16x8_t crw = vshll_n_s8(cr_biased, 7); + int16x8_t cbw = vshll_n_s8(cb_biased, 7); + + // color transform + int16x8_t cr0 = vqdmulhq_s16(crw, cr_const0); + int16x8_t cb0 = vqdmulhq_s16(cbw, cb_const0); + int16x8_t cr1 = vqdmulhq_s16(crw, cr_const1); + int16x8_t cb1 = vqdmulhq_s16(cbw, cb_const1); + int16x8_t rws = vaddq_s16(yws, cr0); + int16x8_t gws = vaddq_s16(vaddq_s16(yws, cb0), cr1); + int16x8_t bws = vaddq_s16(yws, cb1); + + // undo scaling, round, convert to byte + uint8x8x4_t o; + o.val[0] = vqrshrun_n_s16(rws, 4); + o.val[1] = vqrshrun_n_s16(gws, 4); + o.val[2] = vqrshrun_n_s16(bws, 4); + o.val[3] = vdup_n_u8(255); + + // store, interleaving r/g/b/a + vst4_u8(out, o); + out += 8*4; + } + } +#endif + + for (; i < count; ++i) { + int y_fixed = (y[i] << 20) + (1<<19); // rounding + int r,g,b; + int cr = pcr[i] - 128; + int cb = pcb[i] - 128; + r = y_fixed + cr* stbi__float2fixed(1.40200f); + g = y_fixed + cr*-stbi__float2fixed(0.71414f) + ((cb*-stbi__float2fixed(0.34414f)) & 0xffff0000); + b = y_fixed + cb* stbi__float2fixed(1.77200f); + r >>= 20; + g >>= 20; + b >>= 20; + if ((unsigned) r > 255) { if (r < 0) r = 0; else r = 255; } + if ((unsigned) g > 255) { if (g < 0) g = 0; else g = 255; } + if ((unsigned) b > 255) { if (b < 0) b = 0; else b = 255; } + out[0] = (stbi_uc)r; + out[1] = (stbi_uc)g; + out[2] = (stbi_uc)b; + out[3] = 255; + out += step; + } +} +#endif + +// set up the kernels +static void stbi__setup_jpeg(stbi__jpeg *j) +{ + j->idct_block_kernel = stbi__idct_block; + j->YCbCr_to_RGB_kernel = stbi__YCbCr_to_RGB_row; + j->resample_row_hv_2_kernel = stbi__resample_row_hv_2; + +#ifdef STBI_SSE2 + if (stbi__sse2_available()) { + j->idct_block_kernel = stbi__idct_simd; + j->YCbCr_to_RGB_kernel = stbi__YCbCr_to_RGB_simd; + j->resample_row_hv_2_kernel = stbi__resample_row_hv_2_simd; + } +#endif + +#ifdef STBI_NEON + j->idct_block_kernel = stbi__idct_simd; + j->YCbCr_to_RGB_kernel = stbi__YCbCr_to_RGB_simd; + j->resample_row_hv_2_kernel = stbi__resample_row_hv_2_simd; +#endif +} + +// clean up the temporary component buffers +static void stbi__cleanup_jpeg(stbi__jpeg *j) +{ + stbi__free_jpeg_components(j, j->s->img_n, 0); +} + +typedef struct +{ + resample_row_func resample; + stbi_uc *line0,*line1; + int hs,vs; // expansion factor in each axis + int w_lores; // horizontal pixels pre-expansion + int ystep; // how far through vertical expansion we are + int ypos; // which pre-expansion row we're on +} stbi__resample; + +// fast 0..255 * 0..255 => 0..255 rounded multiplication +static stbi_uc stbi__blinn_8x8(stbi_uc x, stbi_uc y) +{ + unsigned int t = x*y + 128; + return (stbi_uc) ((t + (t >>8)) >> 8); +} + +static stbi_uc *load_jpeg_image(stbi__jpeg *z, int *out_x, int *out_y, int *comp, int req_comp) +{ + int n, decode_n, is_rgb; + z->s->img_n = 0; // make stbi__cleanup_jpeg safe + + // validate req_comp + if (req_comp < 0 || req_comp > 4) return stbi__errpuc("bad req_comp", "Internal error"); + + // load a jpeg image from whichever source, but leave in YCbCr format + if (!stbi__decode_jpeg_image(z)) { stbi__cleanup_jpeg(z); return NULL; } + + // determine actual number of components to generate + n = req_comp ? req_comp : z->s->img_n >= 3 ? 3 : 1; + + is_rgb = z->s->img_n == 3 && (z->rgb == 3 || (z->app14_color_transform == 0 && !z->jfif)); + + if (z->s->img_n == 3 && n < 3 && !is_rgb) + decode_n = 1; + else + decode_n = z->s->img_n; + + // resample and color-convert + { + int k; + unsigned int i,j; + stbi_uc *output; + stbi_uc *coutput[4] = { NULL, NULL, NULL, NULL }; + + stbi__resample res_comp[4]; + + for (k=0; k < decode_n; ++k) { + stbi__resample *r = &res_comp[k]; + + // allocate line buffer big enough for upsampling off the edges + // with upsample factor of 4 + z->img_comp[k].linebuf = (stbi_uc *) stbi__malloc(z->s->img_x + 3); + if (!z->img_comp[k].linebuf) { stbi__cleanup_jpeg(z); return stbi__errpuc("outofmem", "Out of memory"); } + + r->hs = z->img_h_max / z->img_comp[k].h; + r->vs = z->img_v_max / z->img_comp[k].v; + r->ystep = r->vs >> 1; + r->w_lores = (z->s->img_x + r->hs-1) / r->hs; + r->ypos = 0; + r->line0 = r->line1 = z->img_comp[k].data; + + if (r->hs == 1 && r->vs == 1) r->resample = resample_row_1; + else if (r->hs == 1 && r->vs == 2) r->resample = stbi__resample_row_v_2; + else if (r->hs == 2 && r->vs == 1) r->resample = stbi__resample_row_h_2; + else if (r->hs == 2 && r->vs == 2) r->resample = z->resample_row_hv_2_kernel; + else r->resample = stbi__resample_row_generic; + } + + // can't error after this so, this is safe + output = (stbi_uc *) stbi__malloc_mad3(n, z->s->img_x, z->s->img_y, 1); + if (!output) { stbi__cleanup_jpeg(z); return stbi__errpuc("outofmem", "Out of memory"); } + + // now go ahead and resample + for (j=0; j < z->s->img_y; ++j) { + stbi_uc *out = output + n * z->s->img_x * j; + for (k=0; k < decode_n; ++k) { + stbi__resample *r = &res_comp[k]; + int y_bot = r->ystep >= (r->vs >> 1); + coutput[k] = r->resample(z->img_comp[k].linebuf, + y_bot ? r->line1 : r->line0, + y_bot ? r->line0 : r->line1, + r->w_lores, r->hs); + if (++r->ystep >= r->vs) { + r->ystep = 0; + r->line0 = r->line1; + if (++r->ypos < z->img_comp[k].y) + r->line1 += z->img_comp[k].w2; + } + } + if (n >= 3) { + stbi_uc *y = coutput[0]; + if (z->s->img_n == 3) { + if (is_rgb) { + for (i=0; i < z->s->img_x; ++i) { + out[0] = y[i]; + out[1] = coutput[1][i]; + out[2] = coutput[2][i]; + out[3] = 255; + out += n; + } + } else { + z->YCbCr_to_RGB_kernel(out, y, coutput[1], coutput[2], z->s->img_x, n); + } + } else if (z->s->img_n == 4) { + if (z->app14_color_transform == 0) { // CMYK + for (i=0; i < z->s->img_x; ++i) { + stbi_uc m = coutput[3][i]; + out[0] = stbi__blinn_8x8(coutput[0][i], m); + out[1] = stbi__blinn_8x8(coutput[1][i], m); + out[2] = stbi__blinn_8x8(coutput[2][i], m); + out[3] = 255; + out += n; + } + } else if (z->app14_color_transform == 2) { // YCCK + z->YCbCr_to_RGB_kernel(out, y, coutput[1], coutput[2], z->s->img_x, n); + for (i=0; i < z->s->img_x; ++i) { + stbi_uc m = coutput[3][i]; + out[0] = stbi__blinn_8x8(255 - out[0], m); + out[1] = stbi__blinn_8x8(255 - out[1], m); + out[2] = stbi__blinn_8x8(255 - out[2], m); + out += n; + } + } else { // YCbCr + alpha? Ignore the fourth channel for now + z->YCbCr_to_RGB_kernel(out, y, coutput[1], coutput[2], z->s->img_x, n); + } + } else + for (i=0; i < z->s->img_x; ++i) { + out[0] = out[1] = out[2] = y[i]; + out[3] = 255; // not used if n==3 + out += n; + } + } else { + if (is_rgb) { + if (n == 1) + for (i=0; i < z->s->img_x; ++i) + *out++ = stbi__compute_y(coutput[0][i], coutput[1][i], coutput[2][i]); + else { + for (i=0; i < z->s->img_x; ++i, out += 2) { + out[0] = stbi__compute_y(coutput[0][i], coutput[1][i], coutput[2][i]); + out[1] = 255; + } + } + } else if (z->s->img_n == 4 && z->app14_color_transform == 0) { + for (i=0; i < z->s->img_x; ++i) { + stbi_uc m = coutput[3][i]; + stbi_uc r = stbi__blinn_8x8(coutput[0][i], m); + stbi_uc g = stbi__blinn_8x8(coutput[1][i], m); + stbi_uc b = stbi__blinn_8x8(coutput[2][i], m); + out[0] = stbi__compute_y(r, g, b); + out[1] = 255; + out += n; + } + } else if (z->s->img_n == 4 && z->app14_color_transform == 2) { + for (i=0; i < z->s->img_x; ++i) { + out[0] = stbi__blinn_8x8(255 - coutput[0][i], coutput[3][i]); + out[1] = 255; + out += n; + } + } else { + stbi_uc *y = coutput[0]; + if (n == 1) + for (i=0; i < z->s->img_x; ++i) out[i] = y[i]; + else + for (i=0; i < z->s->img_x; ++i) { *out++ = y[i]; *out++ = 255; } + } + } + } + stbi__cleanup_jpeg(z); + *out_x = z->s->img_x; + *out_y = z->s->img_y; + if (comp) *comp = z->s->img_n >= 3 ? 3 : 1; // report original components, not output + return output; + } +} + +static void *stbi__jpeg_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +{ + unsigned char* result; + stbi__jpeg* j = (stbi__jpeg*) stbi__malloc(sizeof(stbi__jpeg)); + STBI_NOTUSED(ri); + j->s = s; + stbi__setup_jpeg(j); + result = load_jpeg_image(j, x,y,comp,req_comp); + STBI_FREE(j); + return result; +} + +static int stbi__jpeg_test(stbi__context *s) +{ + int r; + stbi__jpeg* j = (stbi__jpeg*)stbi__malloc(sizeof(stbi__jpeg)); + j->s = s; + stbi__setup_jpeg(j); + r = stbi__decode_jpeg_header(j, STBI__SCAN_type); + stbi__rewind(s); + STBI_FREE(j); + return r; +} + +static int stbi__jpeg_info_raw(stbi__jpeg *j, int *x, int *y, int *comp) +{ + if (!stbi__decode_jpeg_header(j, STBI__SCAN_header)) { + stbi__rewind( j->s ); + return 0; + } + if (x) *x = j->s->img_x; + if (y) *y = j->s->img_y; + if (comp) *comp = j->s->img_n >= 3 ? 3 : 1; + return 1; +} + +static int stbi__jpeg_info(stbi__context *s, int *x, int *y, int *comp) +{ + int result; + stbi__jpeg* j = (stbi__jpeg*) (stbi__malloc(sizeof(stbi__jpeg))); + j->s = s; + result = stbi__jpeg_info_raw(j, x, y, comp); + STBI_FREE(j); + return result; +} +#endif + +// public domain zlib decode v0.2 Sean Barrett 2006-11-18 +// simple implementation +// - all input must be provided in an upfront buffer +// - all output is written to a single output buffer (can malloc/realloc) +// performance +// - fast huffman + +#ifndef STBI_NO_ZLIB + +// fast-way is faster to check than jpeg huffman, but slow way is slower +#define STBI__ZFAST_BITS 9 // accelerate all cases in default tables +#define STBI__ZFAST_MASK ((1 << STBI__ZFAST_BITS) - 1) + +// zlib-style huffman encoding +// (jpegs packs from left, zlib from right, so can't share code) +typedef struct +{ + stbi__uint16 fast[1 << STBI__ZFAST_BITS]; + stbi__uint16 firstcode[16]; + int maxcode[17]; + stbi__uint16 firstsymbol[16]; + stbi_uc size[288]; + stbi__uint16 value[288]; +} stbi__zhuffman; + +stbi_inline static int stbi__bitreverse16(int n) +{ + n = ((n & 0xAAAA) >> 1) | ((n & 0x5555) << 1); + n = ((n & 0xCCCC) >> 2) | ((n & 0x3333) << 2); + n = ((n & 0xF0F0) >> 4) | ((n & 0x0F0F) << 4); + n = ((n & 0xFF00) >> 8) | ((n & 0x00FF) << 8); + return n; +} + +stbi_inline static int stbi__bit_reverse(int v, int bits) +{ + STBI_ASSERT(bits <= 16); + // to bit reverse n bits, reverse 16 and shift + // e.g. 11 bits, bit reverse and shift away 5 + return stbi__bitreverse16(v) >> (16-bits); +} + +static int stbi__zbuild_huffman(stbi__zhuffman *z, const stbi_uc *sizelist, int num) +{ + int i,k=0; + int code, next_code[16], sizes[17]; + + // DEFLATE spec for generating codes + memset(sizes, 0, sizeof(sizes)); + memset(z->fast, 0, sizeof(z->fast)); + for (i=0; i < num; ++i) + ++sizes[sizelist[i]]; + sizes[0] = 0; + for (i=1; i < 16; ++i) + if (sizes[i] > (1 << i)) + return stbi__err("bad sizes", "Corrupt PNG"); + code = 0; + for (i=1; i < 16; ++i) { + next_code[i] = code; + z->firstcode[i] = (stbi__uint16) code; + z->firstsymbol[i] = (stbi__uint16) k; + code = (code + sizes[i]); + if (sizes[i]) + if (code-1 >= (1 << i)) return stbi__err("bad codelengths","Corrupt PNG"); + z->maxcode[i] = code << (16-i); // preshift for inner loop + code <<= 1; + k += sizes[i]; + } + z->maxcode[16] = 0x10000; // sentinel + for (i=0; i < num; ++i) { + int s = sizelist[i]; + if (s) { + int c = next_code[s] - z->firstcode[s] + z->firstsymbol[s]; + stbi__uint16 fastv = (stbi__uint16) ((s << 9) | i); + z->size [c] = (stbi_uc ) s; + z->value[c] = (stbi__uint16) i; + if (s <= STBI__ZFAST_BITS) { + int j = stbi__bit_reverse(next_code[s],s); + while (j < (1 << STBI__ZFAST_BITS)) { + z->fast[j] = fastv; + j += (1 << s); + } + } + ++next_code[s]; + } + } + return 1; +} + +// zlib-from-memory implementation for PNG reading +// because PNG allows splitting the zlib stream arbitrarily, +// and it's annoying structurally to have PNG call ZLIB call PNG, +// we require PNG read all the IDATs and combine them into a single +// memory buffer + +typedef struct +{ + stbi_uc *zbuffer, *zbuffer_end; + int num_bits; + stbi__uint32 code_buffer; + + char *zout; + char *zout_start; + char *zout_end; + int z_expandable; + + stbi__zhuffman z_length, z_distance; +} stbi__zbuf; + +stbi_inline static int stbi__zeof(stbi__zbuf *z) +{ + return (z->zbuffer >= z->zbuffer_end); +} + +stbi_inline static stbi_uc stbi__zget8(stbi__zbuf *z) +{ + return stbi__zeof(z) ? 0 : *z->zbuffer++; +} + +static void stbi__fill_bits(stbi__zbuf *z) +{ + do { + if (z->code_buffer >= (1U << z->num_bits)) { + z->zbuffer = z->zbuffer_end; /* treat this as EOF so we fail. */ + return; + } + z->code_buffer |= (unsigned int) stbi__zget8(z) << z->num_bits; + z->num_bits += 8; + } while (z->num_bits <= 24); +} + +stbi_inline static unsigned int stbi__zreceive(stbi__zbuf *z, int n) +{ + unsigned int k; + if (z->num_bits < n) stbi__fill_bits(z); + k = z->code_buffer & ((1 << n) - 1); + z->code_buffer >>= n; + z->num_bits -= n; + return k; +} + +static int stbi__zhuffman_decode_slowpath(stbi__zbuf *a, stbi__zhuffman *z) +{ + int b,s,k; + // not resolved by fast table, so compute it the slow way + // use jpeg approach, which requires MSbits at top + k = stbi__bit_reverse(a->code_buffer, 16); + for (s=STBI__ZFAST_BITS+1; ; ++s) + if (k < z->maxcode[s]) + break; + if (s >= 16) return -1; // invalid code! + // code size is s, so: + b = (k >> (16-s)) - z->firstcode[s] + z->firstsymbol[s]; + if (b >= sizeof (z->size)) return -1; // some data was corrupt somewhere! + if (z->size[b] != s) return -1; // was originally an assert, but report failure instead. + a->code_buffer >>= s; + a->num_bits -= s; + return z->value[b]; +} + +stbi_inline static int stbi__zhuffman_decode(stbi__zbuf *a, stbi__zhuffman *z) +{ + int b,s; + if (a->num_bits < 16) { + if (stbi__zeof(a)) { + return -1; /* report error for unexpected end of data. */ + } + stbi__fill_bits(a); + } + b = z->fast[a->code_buffer & STBI__ZFAST_MASK]; + if (b) { + s = b >> 9; + a->code_buffer >>= s; + a->num_bits -= s; + return b & 511; + } + return stbi__zhuffman_decode_slowpath(a, z); +} + +static int stbi__zexpand(stbi__zbuf *z, char *zout, int n) // need to make room for n bytes +{ + char *q; + unsigned int cur, limit, old_limit; + z->zout = zout; + if (!z->z_expandable) return stbi__err("output buffer limit","Corrupt PNG"); + cur = (unsigned int) (z->zout - z->zout_start); + limit = old_limit = (unsigned) (z->zout_end - z->zout_start); + if (UINT_MAX - cur < (unsigned) n) return stbi__err("outofmem", "Out of memory"); + while (cur + n > limit) { + if(limit > UINT_MAX / 2) return stbi__err("outofmem", "Out of memory"); + limit *= 2; + } + q = (char *) STBI_REALLOC_SIZED(z->zout_start, old_limit, limit); + STBI_NOTUSED(old_limit); + if (q == NULL) return stbi__err("outofmem", "Out of memory"); + z->zout_start = q; + z->zout = q + cur; + z->zout_end = q + limit; + return 1; +} + +static const int stbi__zlength_base[31] = { + 3,4,5,6,7,8,9,10,11,13, + 15,17,19,23,27,31,35,43,51,59, + 67,83,99,115,131,163,195,227,258,0,0 }; + +static const int stbi__zlength_extra[31]= +{ 0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0 }; + +static const int stbi__zdist_base[32] = { 1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193, +257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577,0,0}; + +static const int stbi__zdist_extra[32] = +{ 0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13}; + +static int stbi__parse_huffman_block(stbi__zbuf *a) +{ + char *zout = a->zout; + for(;;) { + int z = stbi__zhuffman_decode(a, &a->z_length); + if (z < 256) { + if (z < 0) return stbi__err("bad huffman code","Corrupt PNG"); // error in huffman codes + if (zout >= a->zout_end) { + if (!stbi__zexpand(a, zout, 1)) return 0; + zout = a->zout; + } + *zout++ = (char) z; + } else { + stbi_uc *p; + int len,dist; + if (z == 256) { + a->zout = zout; + return 1; + } + z -= 257; + len = stbi__zlength_base[z]; + if (stbi__zlength_extra[z]) len += stbi__zreceive(a, stbi__zlength_extra[z]); + z = stbi__zhuffman_decode(a, &a->z_distance); + if (z < 0) return stbi__err("bad huffman code","Corrupt PNG"); + dist = stbi__zdist_base[z]; + if (stbi__zdist_extra[z]) dist += stbi__zreceive(a, stbi__zdist_extra[z]); + if (zout - a->zout_start < dist) return stbi__err("bad dist","Corrupt PNG"); + if (zout + len > a->zout_end) { + if (!stbi__zexpand(a, zout, len)) return 0; + zout = a->zout; + } + p = (stbi_uc *) (zout - dist); + if (dist == 1) { // run of one byte; common in images. + stbi_uc v = *p; + if (len) { do *zout++ = v; while (--len); } + } else { + if (len) { do *zout++ = *p++; while (--len); } + } + } + } +} + +static int stbi__compute_huffman_codes(stbi__zbuf *a) +{ + static const stbi_uc length_dezigzag[19] = { 16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15 }; + stbi__zhuffman z_codelength; + stbi_uc lencodes[286+32+137];//padding for maximum single op + stbi_uc codelength_sizes[19]; + int i,n; + + int hlit = stbi__zreceive(a,5) + 257; + int hdist = stbi__zreceive(a,5) + 1; + int hclen = stbi__zreceive(a,4) + 4; + int ntot = hlit + hdist; + + memset(codelength_sizes, 0, sizeof(codelength_sizes)); + for (i=0; i < hclen; ++i) { + int s = stbi__zreceive(a,3); + codelength_sizes[length_dezigzag[i]] = (stbi_uc) s; + } + if (!stbi__zbuild_huffman(&z_codelength, codelength_sizes, 19)) return 0; + + n = 0; + while (n < ntot) { + int c = stbi__zhuffman_decode(a, &z_codelength); + if (c < 0 || c >= 19) return stbi__err("bad codelengths", "Corrupt PNG"); + if (c < 16) + lencodes[n++] = (stbi_uc) c; + else { + stbi_uc fill = 0; + if (c == 16) { + c = stbi__zreceive(a,2)+3; + if (n == 0) return stbi__err("bad codelengths", "Corrupt PNG"); + fill = lencodes[n-1]; + } else if (c == 17) { + c = stbi__zreceive(a,3)+3; + } else if (c == 18) { + c = stbi__zreceive(a,7)+11; + } else { + return stbi__err("bad codelengths", "Corrupt PNG"); + } + if (ntot - n < c) return stbi__err("bad codelengths", "Corrupt PNG"); + memset(lencodes+n, fill, c); + n += c; + } + } + if (n != ntot) return stbi__err("bad codelengths","Corrupt PNG"); + if (!stbi__zbuild_huffman(&a->z_length, lencodes, hlit)) return 0; + if (!stbi__zbuild_huffman(&a->z_distance, lencodes+hlit, hdist)) return 0; + return 1; +} + +static int stbi__parse_uncompressed_block(stbi__zbuf *a) +{ + stbi_uc header[4]; + int len,nlen,k; + if (a->num_bits & 7) + stbi__zreceive(a, a->num_bits & 7); // discard + // drain the bit-packed data into header + k = 0; + while (a->num_bits > 0) { + header[k++] = (stbi_uc) (a->code_buffer & 255); // suppress MSVC run-time check + a->code_buffer >>= 8; + a->num_bits -= 8; + } + if (a->num_bits < 0) return stbi__err("zlib corrupt","Corrupt PNG"); + // now fill header the normal way + while (k < 4) + header[k++] = stbi__zget8(a); + len = header[1] * 256 + header[0]; + nlen = header[3] * 256 + header[2]; + if (nlen != (len ^ 0xffff)) return stbi__err("zlib corrupt","Corrupt PNG"); + if (a->zbuffer + len > a->zbuffer_end) return stbi__err("read past buffer","Corrupt PNG"); + if (a->zout + len > a->zout_end) + if (!stbi__zexpand(a, a->zout, len)) return 0; + memcpy(a->zout, a->zbuffer, len); + a->zbuffer += len; + a->zout += len; + return 1; +} + +static int stbi__parse_zlib_header(stbi__zbuf *a) +{ + int cmf = stbi__zget8(a); + int cm = cmf & 15; + /* int cinfo = cmf >> 4; */ + int flg = stbi__zget8(a); + if (stbi__zeof(a)) return stbi__err("bad zlib header","Corrupt PNG"); // zlib spec + if ((cmf*256+flg) % 31 != 0) return stbi__err("bad zlib header","Corrupt PNG"); // zlib spec + if (flg & 32) return stbi__err("no preset dict","Corrupt PNG"); // preset dictionary not allowed in png + if (cm != 8) return stbi__err("bad compression","Corrupt PNG"); // DEFLATE required for png + // window = 1 << (8 + cinfo)... but who cares, we fully buffer output + return 1; +} + +static const stbi_uc stbi__zdefault_length[288] = +{ + 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, + 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, + 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, + 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, + 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, + 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, + 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, + 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, + 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,8,8,8,8,8,8,8,8 +}; +static const stbi_uc stbi__zdefault_distance[32] = +{ + 5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5 +}; +/* +Init algorithm: +{ + int i; // use <= to match clearly with spec + for (i=0; i <= 143; ++i) stbi__zdefault_length[i] = 8; + for ( ; i <= 255; ++i) stbi__zdefault_length[i] = 9; + for ( ; i <= 279; ++i) stbi__zdefault_length[i] = 7; + for ( ; i <= 287; ++i) stbi__zdefault_length[i] = 8; + + for (i=0; i <= 31; ++i) stbi__zdefault_distance[i] = 5; +} +*/ + +static int stbi__parse_zlib(stbi__zbuf *a, int parse_header) +{ + int final, type; + if (parse_header) + if (!stbi__parse_zlib_header(a)) return 0; + a->num_bits = 0; + a->code_buffer = 0; + do { + final = stbi__zreceive(a,1); + type = stbi__zreceive(a,2); + if (type == 0) { + if (!stbi__parse_uncompressed_block(a)) return 0; + } else if (type == 3) { + return 0; + } else { + if (type == 1) { + // use fixed code lengths + if (!stbi__zbuild_huffman(&a->z_length , stbi__zdefault_length , 288)) return 0; + if (!stbi__zbuild_huffman(&a->z_distance, stbi__zdefault_distance, 32)) return 0; + } else { + if (!stbi__compute_huffman_codes(a)) return 0; + } + if (!stbi__parse_huffman_block(a)) return 0; + } + } while (!final); + return 1; +} + +static int stbi__do_zlib(stbi__zbuf *a, char *obuf, int olen, int exp, int parse_header) +{ + a->zout_start = obuf; + a->zout = obuf; + a->zout_end = obuf + olen; + a->z_expandable = exp; + + return stbi__parse_zlib(a, parse_header); +} + +STBIDEF char *stbi_zlib_decode_malloc_guesssize(const char *buffer, int len, int initial_size, int *outlen) +{ + stbi__zbuf a; + char *p = (char *) stbi__malloc(initial_size); + if (p == NULL) return NULL; + a.zbuffer = (stbi_uc *) buffer; + a.zbuffer_end = (stbi_uc *) buffer + len; + if (stbi__do_zlib(&a, p, initial_size, 1, 1)) { + if (outlen) *outlen = (int) (a.zout - a.zout_start); + return a.zout_start; + } else { + STBI_FREE(a.zout_start); + return NULL; + } +} + +STBIDEF char *stbi_zlib_decode_malloc(char const *buffer, int len, int *outlen) +{ + return stbi_zlib_decode_malloc_guesssize(buffer, len, 16384, outlen); +} + +STBIDEF char *stbi_zlib_decode_malloc_guesssize_headerflag(const char *buffer, int len, int initial_size, int *outlen, int parse_header) +{ + stbi__zbuf a; + char *p = (char *) stbi__malloc(initial_size); + if (p == NULL) return NULL; + a.zbuffer = (stbi_uc *) buffer; + a.zbuffer_end = (stbi_uc *) buffer + len; + if (stbi__do_zlib(&a, p, initial_size, 1, parse_header)) { + if (outlen) *outlen = (int) (a.zout - a.zout_start); + return a.zout_start; + } else { + STBI_FREE(a.zout_start); + return NULL; + } +} + +STBIDEF int stbi_zlib_decode_buffer(char *obuffer, int olen, char const *ibuffer, int ilen) +{ + stbi__zbuf a; + a.zbuffer = (stbi_uc *) ibuffer; + a.zbuffer_end = (stbi_uc *) ibuffer + ilen; + if (stbi__do_zlib(&a, obuffer, olen, 0, 1)) + return (int) (a.zout - a.zout_start); + else + return -1; +} + +STBIDEF char *stbi_zlib_decode_noheader_malloc(char const *buffer, int len, int *outlen) +{ + stbi__zbuf a; + char *p = (char *) stbi__malloc(16384); + if (p == NULL) return NULL; + a.zbuffer = (stbi_uc *) buffer; + a.zbuffer_end = (stbi_uc *) buffer+len; + if (stbi__do_zlib(&a, p, 16384, 1, 0)) { + if (outlen) *outlen = (int) (a.zout - a.zout_start); + return a.zout_start; + } else { + STBI_FREE(a.zout_start); + return NULL; + } +} + +STBIDEF int stbi_zlib_decode_noheader_buffer(char *obuffer, int olen, const char *ibuffer, int ilen) +{ + stbi__zbuf a; + a.zbuffer = (stbi_uc *) ibuffer; + a.zbuffer_end = (stbi_uc *) ibuffer + ilen; + if (stbi__do_zlib(&a, obuffer, olen, 0, 0)) + return (int) (a.zout - a.zout_start); + else + return -1; +} +#endif + +// public domain "baseline" PNG decoder v0.10 Sean Barrett 2006-11-18 +// simple implementation +// - only 8-bit samples +// - no CRC checking +// - allocates lots of intermediate memory +// - avoids problem of streaming data between subsystems +// - avoids explicit window management +// performance +// - uses stb_zlib, a PD zlib implementation with fast huffman decoding + +#ifndef STBI_NO_PNG +typedef struct +{ + stbi__uint32 length; + stbi__uint32 type; +} stbi__pngchunk; + +static stbi__pngchunk stbi__get_chunk_header(stbi__context *s) +{ + stbi__pngchunk c; + c.length = stbi__get32be(s); + c.type = stbi__get32be(s); + return c; +} + +static int stbi__check_png_header(stbi__context *s) +{ + static const stbi_uc png_sig[8] = { 137,80,78,71,13,10,26,10 }; + int i; + for (i=0; i < 8; ++i) + if (stbi__get8(s) != png_sig[i]) return stbi__err("bad png sig","Not a PNG"); + return 1; +} + +typedef struct +{ + stbi__context *s; + stbi_uc *idata, *expanded, *out; + int depth; +} stbi__png; + + +enum { + STBI__F_none=0, + STBI__F_sub=1, + STBI__F_up=2, + STBI__F_avg=3, + STBI__F_paeth=4, + // synthetic filters used for first scanline to avoid needing a dummy row of 0s + STBI__F_avg_first, + STBI__F_paeth_first +}; + +static stbi_uc first_row_filter[5] = +{ + STBI__F_none, + STBI__F_sub, + STBI__F_none, + STBI__F_avg_first, + STBI__F_paeth_first +}; + +static int stbi__paeth(int a, int b, int c) +{ + int p = a + b - c; + int pa = abs(p-a); + int pb = abs(p-b); + int pc = abs(p-c); + if (pa <= pb && pa <= pc) return a; + if (pb <= pc) return b; + return c; +} + +static const stbi_uc stbi__depth_scale_table[9] = { 0, 0xff, 0x55, 0, 0x11, 0,0,0, 0x01 }; + +// create the png data from post-deflated data +static int stbi__create_png_image_raw(stbi__png *a, stbi_uc *raw, stbi__uint32 raw_len, int out_n, stbi__uint32 x, stbi__uint32 y, int depth, int color) +{ + int bytes = (depth == 16? 2 : 1); + stbi__context *s = a->s; + stbi__uint32 i,j,stride = x*out_n*bytes; + stbi__uint32 img_len, img_width_bytes; + int k; + int img_n = s->img_n; // copy it into a local for later + + int output_bytes = out_n*bytes; + int filter_bytes = img_n*bytes; + int width = x; + + STBI_ASSERT(out_n == s->img_n || out_n == s->img_n+1); + a->out = (stbi_uc *) stbi__malloc_mad3(x, y, output_bytes, 0); // extra bytes to write off the end into + if (!a->out) return stbi__err("outofmem", "Out of memory"); + + if (!stbi__mad3sizes_valid(img_n, x, depth, 7)) return stbi__err("too large", "Corrupt PNG"); + img_width_bytes = (((img_n * x * depth) + 7) >> 3); + img_len = (img_width_bytes + 1) * y; + + // we used to check for exact match between raw_len and img_len on non-interlaced PNGs, + // but issue #276 reported a PNG in the wild that had extra data at the end (all zeros), + // so just check for raw_len < img_len always. + if (raw_len < img_len) return stbi__err("not enough pixels","Corrupt PNG"); + + for (j=0; j < y; ++j) { + stbi_uc *cur = a->out + stride*j; + stbi_uc *prior; + int filter = *raw++; + + if (filter > 4) + return stbi__err("invalid filter","Corrupt PNG"); + + if (depth < 8) { + if (img_width_bytes > x) return stbi__err("invalid width","Corrupt PNG"); + cur += x*out_n - img_width_bytes; // store output to the rightmost img_len bytes, so we can decode in place + filter_bytes = 1; + width = img_width_bytes; + } + prior = cur - stride; // bugfix: need to compute this after 'cur +=' computation above + + // if first row, use special filter that doesn't sample previous row + if (j == 0) filter = first_row_filter[filter]; + + // handle first byte explicitly + for (k=0; k < filter_bytes; ++k) { + switch (filter) { + case STBI__F_none : cur[k] = raw[k]; break; + case STBI__F_sub : cur[k] = raw[k]; break; + case STBI__F_up : cur[k] = STBI__BYTECAST(raw[k] + prior[k]); break; + case STBI__F_avg : cur[k] = STBI__BYTECAST(raw[k] + (prior[k]>>1)); break; + case STBI__F_paeth : cur[k] = STBI__BYTECAST(raw[k] + stbi__paeth(0,prior[k],0)); break; + case STBI__F_avg_first : cur[k] = raw[k]; break; + case STBI__F_paeth_first: cur[k] = raw[k]; break; + } + } + + if (depth == 8) { + if (img_n != out_n) + cur[img_n] = 255; // first pixel + raw += img_n; + cur += out_n; + prior += out_n; + } else if (depth == 16) { + if (img_n != out_n) { + cur[filter_bytes] = 255; // first pixel top byte + cur[filter_bytes+1] = 255; // first pixel bottom byte + } + raw += filter_bytes; + cur += output_bytes; + prior += output_bytes; + } else { + raw += 1; + cur += 1; + prior += 1; + } + + // this is a little gross, so that we don't switch per-pixel or per-component + if (depth < 8 || img_n == out_n) { + int nk = (width - 1)*filter_bytes; + #define STBI__CASE(f) \ + case f: \ + for (k=0; k < nk; ++k) + switch (filter) { + // "none" filter turns into a memcpy here; make that explicit. + case STBI__F_none: memcpy(cur, raw, nk); break; + STBI__CASE(STBI__F_sub) { cur[k] = STBI__BYTECAST(raw[k] + cur[k-filter_bytes]); } break; + STBI__CASE(STBI__F_up) { cur[k] = STBI__BYTECAST(raw[k] + prior[k]); } break; + STBI__CASE(STBI__F_avg) { cur[k] = STBI__BYTECAST(raw[k] + ((prior[k] + cur[k-filter_bytes])>>1)); } break; + STBI__CASE(STBI__F_paeth) { cur[k] = STBI__BYTECAST(raw[k] + stbi__paeth(cur[k-filter_bytes],prior[k],prior[k-filter_bytes])); } break; + STBI__CASE(STBI__F_avg_first) { cur[k] = STBI__BYTECAST(raw[k] + (cur[k-filter_bytes] >> 1)); } break; + STBI__CASE(STBI__F_paeth_first) { cur[k] = STBI__BYTECAST(raw[k] + stbi__paeth(cur[k-filter_bytes],0,0)); } break; + } + #undef STBI__CASE + raw += nk; + } else { + STBI_ASSERT(img_n+1 == out_n); + #define STBI__CASE(f) \ + case f: \ + for (i=x-1; i >= 1; --i, cur[filter_bytes]=255,raw+=filter_bytes,cur+=output_bytes,prior+=output_bytes) \ + for (k=0; k < filter_bytes; ++k) + switch (filter) { + STBI__CASE(STBI__F_none) { cur[k] = raw[k]; } break; + STBI__CASE(STBI__F_sub) { cur[k] = STBI__BYTECAST(raw[k] + cur[k- output_bytes]); } break; + STBI__CASE(STBI__F_up) { cur[k] = STBI__BYTECAST(raw[k] + prior[k]); } break; + STBI__CASE(STBI__F_avg) { cur[k] = STBI__BYTECAST(raw[k] + ((prior[k] + cur[k- output_bytes])>>1)); } break; + STBI__CASE(STBI__F_paeth) { cur[k] = STBI__BYTECAST(raw[k] + stbi__paeth(cur[k- output_bytes],prior[k],prior[k- output_bytes])); } break; + STBI__CASE(STBI__F_avg_first) { cur[k] = STBI__BYTECAST(raw[k] + (cur[k- output_bytes] >> 1)); } break; + STBI__CASE(STBI__F_paeth_first) { cur[k] = STBI__BYTECAST(raw[k] + stbi__paeth(cur[k- output_bytes],0,0)); } break; + } + #undef STBI__CASE + + // the loop above sets the high byte of the pixels' alpha, but for + // 16 bit png files we also need the low byte set. we'll do that here. + if (depth == 16) { + cur = a->out + stride*j; // start at the beginning of the row again + for (i=0; i < x; ++i,cur+=output_bytes) { + cur[filter_bytes+1] = 255; + } + } + } + } + + // we make a separate pass to expand bits to pixels; for performance, + // this could run two scanlines behind the above code, so it won't + // intefere with filtering but will still be in the cache. + if (depth < 8) { + for (j=0; j < y; ++j) { + stbi_uc *cur = a->out + stride*j; + stbi_uc *in = a->out + stride*j + x*out_n - img_width_bytes; + // unpack 1/2/4-bit into a 8-bit buffer. allows us to keep the common 8-bit path optimal at minimal cost for 1/2/4-bit + // png guarante byte alignment, if width is not multiple of 8/4/2 we'll decode dummy trailing data that will be skipped in the later loop + stbi_uc scale = (color == 0) ? stbi__depth_scale_table[depth] : 1; // scale grayscale values to 0..255 range + + // note that the final byte might overshoot and write more data than desired. + // we can allocate enough data that this never writes out of memory, but it + // could also overwrite the next scanline. can it overwrite non-empty data + // on the next scanline? yes, consider 1-pixel-wide scanlines with 1-bit-per-pixel. + // so we need to explicitly clamp the final ones + + if (depth == 4) { + for (k=x*img_n; k >= 2; k-=2, ++in) { + *cur++ = scale * ((*in >> 4) ); + *cur++ = scale * ((*in ) & 0x0f); + } + if (k > 0) *cur++ = scale * ((*in >> 4) ); + } else if (depth == 2) { + for (k=x*img_n; k >= 4; k-=4, ++in) { + *cur++ = scale * ((*in >> 6) ); + *cur++ = scale * ((*in >> 4) & 0x03); + *cur++ = scale * ((*in >> 2) & 0x03); + *cur++ = scale * ((*in ) & 0x03); + } + if (k > 0) *cur++ = scale * ((*in >> 6) ); + if (k > 1) *cur++ = scale * ((*in >> 4) & 0x03); + if (k > 2) *cur++ = scale * ((*in >> 2) & 0x03); + } else if (depth == 1) { + for (k=x*img_n; k >= 8; k-=8, ++in) { + *cur++ = scale * ((*in >> 7) ); + *cur++ = scale * ((*in >> 6) & 0x01); + *cur++ = scale * ((*in >> 5) & 0x01); + *cur++ = scale * ((*in >> 4) & 0x01); + *cur++ = scale * ((*in >> 3) & 0x01); + *cur++ = scale * ((*in >> 2) & 0x01); + *cur++ = scale * ((*in >> 1) & 0x01); + *cur++ = scale * ((*in ) & 0x01); + } + if (k > 0) *cur++ = scale * ((*in >> 7) ); + if (k > 1) *cur++ = scale * ((*in >> 6) & 0x01); + if (k > 2) *cur++ = scale * ((*in >> 5) & 0x01); + if (k > 3) *cur++ = scale * ((*in >> 4) & 0x01); + if (k > 4) *cur++ = scale * ((*in >> 3) & 0x01); + if (k > 5) *cur++ = scale * ((*in >> 2) & 0x01); + if (k > 6) *cur++ = scale * ((*in >> 1) & 0x01); + } + if (img_n != out_n) { + int q; + // insert alpha = 255 + cur = a->out + stride*j; + if (img_n == 1) { + for (q=x-1; q >= 0; --q) { + cur[q*2+1] = 255; + cur[q*2+0] = cur[q]; + } + } else { + STBI_ASSERT(img_n == 3); + for (q=x-1; q >= 0; --q) { + cur[q*4+3] = 255; + cur[q*4+2] = cur[q*3+2]; + cur[q*4+1] = cur[q*3+1]; + cur[q*4+0] = cur[q*3+0]; + } + } + } + } + } else if (depth == 16) { + // force the image data from big-endian to platform-native. + // this is done in a separate pass due to the decoding relying + // on the data being untouched, but could probably be done + // per-line during decode if care is taken. + stbi_uc *cur = a->out; + stbi__uint16 *cur16 = (stbi__uint16*)cur; + + for(i=0; i < x*y*out_n; ++i,cur16++,cur+=2) { + *cur16 = (cur[0] << 8) | cur[1]; + } + } + + return 1; +} + +static int stbi__create_png_image(stbi__png *a, stbi_uc *image_data, stbi__uint32 image_data_len, int out_n, int depth, int color, int interlaced) +{ + int bytes = (depth == 16 ? 2 : 1); + int out_bytes = out_n * bytes; + stbi_uc *final; + int p; + if (!interlaced) + return stbi__create_png_image_raw(a, image_data, image_data_len, out_n, a->s->img_x, a->s->img_y, depth, color); + + // de-interlacing + final = (stbi_uc *) stbi__malloc_mad3(a->s->img_x, a->s->img_y, out_bytes, 0); + for (p=0; p < 7; ++p) { + int xorig[] = { 0,4,0,2,0,1,0 }; + int yorig[] = { 0,0,4,0,2,0,1 }; + int xspc[] = { 8,8,4,4,2,2,1 }; + int yspc[] = { 8,8,8,4,4,2,2 }; + int i,j,x,y; + // pass1_x[4] = 0, pass1_x[5] = 1, pass1_x[12] = 1 + x = (a->s->img_x - xorig[p] + xspc[p]-1) / xspc[p]; + y = (a->s->img_y - yorig[p] + yspc[p]-1) / yspc[p]; + if (x && y) { + stbi__uint32 img_len = ((((a->s->img_n * x * depth) + 7) >> 3) + 1) * y; + if (!stbi__create_png_image_raw(a, image_data, image_data_len, out_n, x, y, depth, color)) { + STBI_FREE(final); + return 0; + } + for (j=0; j < y; ++j) { + for (i=0; i < x; ++i) { + int out_y = j*yspc[p]+yorig[p]; + int out_x = i*xspc[p]+xorig[p]; + memcpy(final + out_y*a->s->img_x*out_bytes + out_x*out_bytes, + a->out + (j*x+i)*out_bytes, out_bytes); + } + } + STBI_FREE(a->out); + image_data += img_len; + image_data_len -= img_len; + } + } + a->out = final; + + return 1; +} + +static int stbi__compute_transparency(stbi__png *z, stbi_uc tc[3], int out_n) +{ + stbi__context *s = z->s; + stbi__uint32 i, pixel_count = s->img_x * s->img_y; + stbi_uc *p = z->out; + + // compute color-based transparency, assuming we've + // already got 255 as the alpha value in the output + STBI_ASSERT(out_n == 2 || out_n == 4); + + if (out_n == 2) { + for (i=0; i < pixel_count; ++i) { + p[1] = (p[0] == tc[0] ? 0 : 255); + p += 2; + } + } else { + for (i=0; i < pixel_count; ++i) { + if (p[0] == tc[0] && p[1] == tc[1] && p[2] == tc[2]) + p[3] = 0; + p += 4; + } + } + return 1; +} + +static int stbi__compute_transparency16(stbi__png *z, stbi__uint16 tc[3], int out_n) +{ + stbi__context *s = z->s; + stbi__uint32 i, pixel_count = s->img_x * s->img_y; + stbi__uint16 *p = (stbi__uint16*) z->out; + + // compute color-based transparency, assuming we've + // already got 65535 as the alpha value in the output + STBI_ASSERT(out_n == 2 || out_n == 4); + + if (out_n == 2) { + for (i = 0; i < pixel_count; ++i) { + p[1] = (p[0] == tc[0] ? 0 : 65535); + p += 2; + } + } else { + for (i = 0; i < pixel_count; ++i) { + if (p[0] == tc[0] && p[1] == tc[1] && p[2] == tc[2]) + p[3] = 0; + p += 4; + } + } + return 1; +} + +static int stbi__expand_png_palette(stbi__png *a, stbi_uc *palette, int len, int pal_img_n) +{ + stbi__uint32 i, pixel_count = a->s->img_x * a->s->img_y; + stbi_uc *p, *temp_out, *orig = a->out; + + p = (stbi_uc *) stbi__malloc_mad2(pixel_count, pal_img_n, 0); + if (p == NULL) return stbi__err("outofmem", "Out of memory"); + + // between here and free(out) below, exitting would leak + temp_out = p; + + if (pal_img_n == 3) { + for (i=0; i < pixel_count; ++i) { + int n = orig[i]*4; + p[0] = palette[n ]; + p[1] = palette[n+1]; + p[2] = palette[n+2]; + p += 3; + } + } else { + for (i=0; i < pixel_count; ++i) { + int n = orig[i]*4; + p[0] = palette[n ]; + p[1] = palette[n+1]; + p[2] = palette[n+2]; + p[3] = palette[n+3]; + p += 4; + } + } + STBI_FREE(a->out); + a->out = temp_out; + + STBI_NOTUSED(len); + + return 1; +} + +static int stbi__unpremultiply_on_load = 0; +static int stbi__de_iphone_flag = 0; + +STBIDEF void stbi_set_unpremultiply_on_load(int flag_true_if_should_unpremultiply) +{ + stbi__unpremultiply_on_load = flag_true_if_should_unpremultiply; +} + +STBIDEF void stbi_convert_iphone_png_to_rgb(int flag_true_if_should_convert) +{ + stbi__de_iphone_flag = flag_true_if_should_convert; +} + +static void stbi__de_iphone(stbi__png *z) +{ + stbi__context *s = z->s; + stbi__uint32 i, pixel_count = s->img_x * s->img_y; + stbi_uc *p = z->out; + + if (s->img_out_n == 3) { // convert bgr to rgb + for (i=0; i < pixel_count; ++i) { + stbi_uc t = p[0]; + p[0] = p[2]; + p[2] = t; + p += 3; + } + } else { + STBI_ASSERT(s->img_out_n == 4); + if (stbi__unpremultiply_on_load) { + // convert bgr to rgb and unpremultiply + for (i=0; i < pixel_count; ++i) { + stbi_uc a = p[3]; + stbi_uc t = p[0]; + if (a) { + stbi_uc half = a / 2; + p[0] = (p[2] * 255 + half) / a; + p[1] = (p[1] * 255 + half) / a; + p[2] = ( t * 255 + half) / a; + } else { + p[0] = p[2]; + p[2] = t; + } + p += 4; + } + } else { + // convert bgr to rgb + for (i=0; i < pixel_count; ++i) { + stbi_uc t = p[0]; + p[0] = p[2]; + p[2] = t; + p += 4; + } + } + } +} + +#define STBI__PNG_TYPE(a,b,c,d) (((unsigned) (a) << 24) + ((unsigned) (b) << 16) + ((unsigned) (c) << 8) + (unsigned) (d)) + +static int stbi__parse_png_file(stbi__png *z, int scan, int req_comp) +{ + stbi_uc palette[1024], pal_img_n=0; + stbi_uc has_trans=0, tc[3]={0}; + stbi__uint16 tc16[3]; + stbi__uint32 ioff=0, idata_limit=0, i, pal_len=0; + int first=1,k,interlace=0, color=0, is_iphone=0; + stbi__context *s = z->s; + + z->expanded = NULL; + z->idata = NULL; + z->out = NULL; + + if (!stbi__check_png_header(s)) return 0; + + if (scan == STBI__SCAN_type) return 1; + + for (;;) { + stbi__pngchunk c = stbi__get_chunk_header(s); + switch (c.type) { + case STBI__PNG_TYPE('C','g','B','I'): + is_iphone = 1; + stbi__skip(s, c.length); + break; + case STBI__PNG_TYPE('I','H','D','R'): { + int comp,filter; + if (!first) return stbi__err("multiple IHDR","Corrupt PNG"); + first = 0; + if (c.length != 13) return stbi__err("bad IHDR len","Corrupt PNG"); + s->img_x = stbi__get32be(s); + s->img_y = stbi__get32be(s); + if (s->img_y > STBI_MAX_DIMENSIONS) return stbi__err("too large","Very large image (corrupt?)"); + if (s->img_x > STBI_MAX_DIMENSIONS) return stbi__err("too large","Very large image (corrupt?)"); + z->depth = stbi__get8(s); if (z->depth != 1 && z->depth != 2 && z->depth != 4 && z->depth != 8 && z->depth != 16) return stbi__err("1/2/4/8/16-bit only","PNG not supported: 1/2/4/8/16-bit only"); + color = stbi__get8(s); if (color > 6) return stbi__err("bad ctype","Corrupt PNG"); + if (color == 3 && z->depth == 16) return stbi__err("bad ctype","Corrupt PNG"); + if (color == 3) pal_img_n = 3; else if (color & 1) return stbi__err("bad ctype","Corrupt PNG"); + comp = stbi__get8(s); if (comp) return stbi__err("bad comp method","Corrupt PNG"); + filter= stbi__get8(s); if (filter) return stbi__err("bad filter method","Corrupt PNG"); + interlace = stbi__get8(s); if (interlace>1) return stbi__err("bad interlace method","Corrupt PNG"); + if (!s->img_x || !s->img_y) return stbi__err("0-pixel image","Corrupt PNG"); + if (!pal_img_n) { + s->img_n = (color & 2 ? 3 : 1) + (color & 4 ? 1 : 0); + if ((1 << 30) / s->img_x / s->img_n < s->img_y) return stbi__err("too large", "Image too large to decode"); + if (scan == STBI__SCAN_header) return 1; + } else { + // if paletted, then pal_n is our final components, and + // img_n is # components to decompress/filter. + s->img_n = 1; + if ((1 << 30) / s->img_x / 4 < s->img_y) return stbi__err("too large","Corrupt PNG"); + // if SCAN_header, have to scan to see if we have a tRNS + } + break; + } + + case STBI__PNG_TYPE('P','L','T','E'): { + if (first) return stbi__err("first not IHDR", "Corrupt PNG"); + if (c.length > 256*3) return stbi__err("invalid PLTE","Corrupt PNG"); + pal_len = c.length / 3; + if (pal_len * 3 != c.length) return stbi__err("invalid PLTE","Corrupt PNG"); + for (i=0; i < pal_len; ++i) { + palette[i*4+0] = stbi__get8(s); + palette[i*4+1] = stbi__get8(s); + palette[i*4+2] = stbi__get8(s); + palette[i*4+3] = 255; + } + break; + } + + case STBI__PNG_TYPE('t','R','N','S'): { + if (first) return stbi__err("first not IHDR", "Corrupt PNG"); + if (z->idata) return stbi__err("tRNS after IDAT","Corrupt PNG"); + if (pal_img_n) { + if (scan == STBI__SCAN_header) { s->img_n = 4; return 1; } + if (pal_len == 0) return stbi__err("tRNS before PLTE","Corrupt PNG"); + if (c.length > pal_len) return stbi__err("bad tRNS len","Corrupt PNG"); + pal_img_n = 4; + for (i=0; i < c.length; ++i) + palette[i*4+3] = stbi__get8(s); + } else { + if (!(s->img_n & 1)) return stbi__err("tRNS with alpha","Corrupt PNG"); + if (c.length != (stbi__uint32) s->img_n*2) return stbi__err("bad tRNS len","Corrupt PNG"); + has_trans = 1; + if (z->depth == 16) { + for (k = 0; k < s->img_n; ++k) tc16[k] = (stbi__uint16)stbi__get16be(s); // copy the values as-is + } else { + for (k = 0; k < s->img_n; ++k) tc[k] = (stbi_uc)(stbi__get16be(s) & 255) * stbi__depth_scale_table[z->depth]; // non 8-bit images will be larger + } + } + break; + } + + case STBI__PNG_TYPE('I','D','A','T'): { + if (first) return stbi__err("first not IHDR", "Corrupt PNG"); + if (pal_img_n && !pal_len) return stbi__err("no PLTE","Corrupt PNG"); + if (scan == STBI__SCAN_header) { s->img_n = pal_img_n; return 1; } + if ((int)(ioff + c.length) < (int)ioff) return 0; + if (ioff + c.length > idata_limit) { + stbi__uint32 idata_limit_old = idata_limit; + stbi_uc *p; + if (idata_limit == 0) idata_limit = c.length > 4096 ? c.length : 4096; + while (ioff + c.length > idata_limit) + idata_limit *= 2; + STBI_NOTUSED(idata_limit_old); + p = (stbi_uc *) STBI_REALLOC_SIZED(z->idata, idata_limit_old, idata_limit); if (p == NULL) return stbi__err("outofmem", "Out of memory"); + z->idata = p; + } + if (!stbi__getn(s, z->idata+ioff,c.length)) return stbi__err("outofdata","Corrupt PNG"); + ioff += c.length; + break; + } + + case STBI__PNG_TYPE('I','E','N','D'): { + stbi__uint32 raw_len, bpl; + if (first) return stbi__err("first not IHDR", "Corrupt PNG"); + if (scan != STBI__SCAN_load) return 1; + if (z->idata == NULL) return stbi__err("no IDAT","Corrupt PNG"); + // initial guess for decoded data size to avoid unnecessary reallocs + bpl = (s->img_x * z->depth + 7) / 8; // bytes per line, per component + raw_len = bpl * s->img_y * s->img_n /* pixels */ + s->img_y /* filter mode per row */; + z->expanded = (stbi_uc *) stbi_zlib_decode_malloc_guesssize_headerflag((char *) z->idata, ioff, raw_len, (int *) &raw_len, !is_iphone); + if (z->expanded == NULL) return 0; // zlib should set error + STBI_FREE(z->idata); z->idata = NULL; + if ((req_comp == s->img_n+1 && req_comp != 3 && !pal_img_n) || has_trans) + s->img_out_n = s->img_n+1; + else + s->img_out_n = s->img_n; + if (!stbi__create_png_image(z, z->expanded, raw_len, s->img_out_n, z->depth, color, interlace)) return 0; + if (has_trans) { + if (z->depth == 16) { + if (!stbi__compute_transparency16(z, tc16, s->img_out_n)) return 0; + } else { + if (!stbi__compute_transparency(z, tc, s->img_out_n)) return 0; + } + } + if (is_iphone && stbi__de_iphone_flag && s->img_out_n > 2) + stbi__de_iphone(z); + if (pal_img_n) { + // pal_img_n == 3 or 4 + s->img_n = pal_img_n; // record the actual colors we had + s->img_out_n = pal_img_n; + if (req_comp >= 3) s->img_out_n = req_comp; + if (!stbi__expand_png_palette(z, palette, pal_len, s->img_out_n)) + return 0; + } else if (has_trans) { + // non-paletted image with tRNS -> source image has (constant) alpha + ++s->img_n; + } + STBI_FREE(z->expanded); z->expanded = NULL; + // end of PNG chunk, read and skip CRC + stbi__get32be(s); + return 1; + } + + default: + // if critical, fail + if (first) return stbi__err("first not IHDR", "Corrupt PNG"); + if ((c.type & (1 << 29)) == 0) { + #ifndef STBI_NO_FAILURE_STRINGS + // not threadsafe + static char invalid_chunk[] = "XXXX PNG chunk not known"; + invalid_chunk[0] = STBI__BYTECAST(c.type >> 24); + invalid_chunk[1] = STBI__BYTECAST(c.type >> 16); + invalid_chunk[2] = STBI__BYTECAST(c.type >> 8); + invalid_chunk[3] = STBI__BYTECAST(c.type >> 0); + #endif + return stbi__err(invalid_chunk, "PNG not supported: unknown PNG chunk type"); + } + stbi__skip(s, c.length); + break; + } + // end of PNG chunk, read and skip CRC + stbi__get32be(s); + } +} + +static void *stbi__do_png(stbi__png *p, int *x, int *y, int *n, int req_comp, stbi__result_info *ri) +{ + void *result=NULL; + if (req_comp < 0 || req_comp > 4) return stbi__errpuc("bad req_comp", "Internal error"); + if (stbi__parse_png_file(p, STBI__SCAN_load, req_comp)) { + if (p->depth <= 8) + ri->bits_per_channel = 8; + else if (p->depth == 16) + ri->bits_per_channel = 16; + else + return stbi__errpuc("bad bits_per_channel", "PNG not supported: unsupported color depth"); + result = p->out; + p->out = NULL; + if (req_comp && req_comp != p->s->img_out_n) { + if (ri->bits_per_channel == 8) + result = stbi__convert_format((unsigned char *) result, p->s->img_out_n, req_comp, p->s->img_x, p->s->img_y); + else + result = stbi__convert_format16((stbi__uint16 *) result, p->s->img_out_n, req_comp, p->s->img_x, p->s->img_y); + p->s->img_out_n = req_comp; + if (result == NULL) return result; + } + *x = p->s->img_x; + *y = p->s->img_y; + if (n) *n = p->s->img_n; + } + STBI_FREE(p->out); p->out = NULL; + STBI_FREE(p->expanded); p->expanded = NULL; + STBI_FREE(p->idata); p->idata = NULL; + + return result; +} + +static void *stbi__png_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +{ + stbi__png p; + p.s = s; + return stbi__do_png(&p, x,y,comp,req_comp, ri); +} + +static int stbi__png_test(stbi__context *s) +{ + int r; + r = stbi__check_png_header(s); + stbi__rewind(s); + return r; +} + +static int stbi__png_info_raw(stbi__png *p, int *x, int *y, int *comp) +{ + if (!stbi__parse_png_file(p, STBI__SCAN_header, 0)) { + stbi__rewind( p->s ); + return 0; + } + if (x) *x = p->s->img_x; + if (y) *y = p->s->img_y; + if (comp) *comp = p->s->img_n; + return 1; +} + +static int stbi__png_info(stbi__context *s, int *x, int *y, int *comp) +{ + stbi__png p; + p.s = s; + return stbi__png_info_raw(&p, x, y, comp); +} + +static int stbi__png_is16(stbi__context *s) +{ + stbi__png p; + p.s = s; + if (!stbi__png_info_raw(&p, NULL, NULL, NULL)) + return 0; + if (p.depth != 16) { + stbi__rewind(p.s); + return 0; + } + return 1; +} +#endif + +// Microsoft/Windows BMP image + +#ifndef STBI_NO_BMP +static int stbi__bmp_test_raw(stbi__context *s) +{ + int r; + int sz; + if (stbi__get8(s) != 'B') return 0; + if (stbi__get8(s) != 'M') return 0; + stbi__get32le(s); // discard filesize + stbi__get16le(s); // discard reserved + stbi__get16le(s); // discard reserved + stbi__get32le(s); // discard data offset + sz = stbi__get32le(s); + r = (sz == 12 || sz == 40 || sz == 56 || sz == 108 || sz == 124); + return r; +} + +static int stbi__bmp_test(stbi__context *s) +{ + int r = stbi__bmp_test_raw(s); + stbi__rewind(s); + return r; +} + + +// returns 0..31 for the highest set bit +static int stbi__high_bit(unsigned int z) +{ + int n=0; + if (z == 0) return -1; + if (z >= 0x10000) { n += 16; z >>= 16; } + if (z >= 0x00100) { n += 8; z >>= 8; } + if (z >= 0x00010) { n += 4; z >>= 4; } + if (z >= 0x00004) { n += 2; z >>= 2; } + if (z >= 0x00002) { n += 1;/* >>= 1;*/ } + return n; +} + +static int stbi__bitcount(unsigned int a) +{ + a = (a & 0x55555555) + ((a >> 1) & 0x55555555); // max 2 + a = (a & 0x33333333) + ((a >> 2) & 0x33333333); // max 4 + a = (a + (a >> 4)) & 0x0f0f0f0f; // max 8 per 4, now 8 bits + a = (a + (a >> 8)); // max 16 per 8 bits + a = (a + (a >> 16)); // max 32 per 8 bits + return a & 0xff; +} + +// extract an arbitrarily-aligned N-bit value (N=bits) +// from v, and then make it 8-bits long and fractionally +// extend it to full full range. +static int stbi__shiftsigned(unsigned int v, int shift, int bits) +{ + static unsigned int mul_table[9] = { + 0, + 0xff/*0b11111111*/, 0x55/*0b01010101*/, 0x49/*0b01001001*/, 0x11/*0b00010001*/, + 0x21/*0b00100001*/, 0x41/*0b01000001*/, 0x81/*0b10000001*/, 0x01/*0b00000001*/, + }; + static unsigned int shift_table[9] = { + 0, 0,0,1,0,2,4,6,0, + }; + if (shift < 0) + v <<= -shift; + else + v >>= shift; + STBI_ASSERT(v < 256); + v >>= (8-bits); + STBI_ASSERT(bits >= 0 && bits <= 8); + return (int) ((unsigned) v * mul_table[bits]) >> shift_table[bits]; +} + +typedef struct +{ + int bpp, offset, hsz; + unsigned int mr,mg,mb,ma, all_a; + int extra_read; +} stbi__bmp_data; + +static void *stbi__bmp_parse_header(stbi__context *s, stbi__bmp_data *info) +{ + int hsz; + if (stbi__get8(s) != 'B' || stbi__get8(s) != 'M') return stbi__errpuc("not BMP", "Corrupt BMP"); + stbi__get32le(s); // discard filesize + stbi__get16le(s); // discard reserved + stbi__get16le(s); // discard reserved + info->offset = stbi__get32le(s); + info->hsz = hsz = stbi__get32le(s); + info->mr = info->mg = info->mb = info->ma = 0; + info->extra_read = 14; + + if (info->offset < 0) return stbi__errpuc("bad BMP", "bad BMP"); + + if (hsz != 12 && hsz != 40 && hsz != 56 && hsz != 108 && hsz != 124) return stbi__errpuc("unknown BMP", "BMP type not supported: unknown"); + if (hsz == 12) { + s->img_x = stbi__get16le(s); + s->img_y = stbi__get16le(s); + } else { + s->img_x = stbi__get32le(s); + s->img_y = stbi__get32le(s); + } + if (stbi__get16le(s) != 1) return stbi__errpuc("bad BMP", "bad BMP"); + info->bpp = stbi__get16le(s); + if (hsz != 12) { + int compress = stbi__get32le(s); + if (compress == 1 || compress == 2) return stbi__errpuc("BMP RLE", "BMP type not supported: RLE"); + stbi__get32le(s); // discard sizeof + stbi__get32le(s); // discard hres + stbi__get32le(s); // discard vres + stbi__get32le(s); // discard colorsused + stbi__get32le(s); // discard max important + if (hsz == 40 || hsz == 56) { + if (hsz == 56) { + stbi__get32le(s); + stbi__get32le(s); + stbi__get32le(s); + stbi__get32le(s); + } + if (info->bpp == 16 || info->bpp == 32) { + if (compress == 0) { + if (info->bpp == 32) { + info->mr = 0xffu << 16; + info->mg = 0xffu << 8; + info->mb = 0xffu << 0; + info->ma = 0xffu << 24; + info->all_a = 0; // if all_a is 0 at end, then we loaded alpha channel but it was all 0 + } else { + info->mr = 31u << 10; + info->mg = 31u << 5; + info->mb = 31u << 0; + } + } else if (compress == 3) { + info->mr = stbi__get32le(s); + info->mg = stbi__get32le(s); + info->mb = stbi__get32le(s); + info->extra_read += 12; + // not documented, but generated by photoshop and handled by mspaint + if (info->mr == info->mg && info->mg == info->mb) { + // ?!?!? + return stbi__errpuc("bad BMP", "bad BMP"); + } + } else + return stbi__errpuc("bad BMP", "bad BMP"); + } + } else { + int i; + if (hsz != 108 && hsz != 124) + return stbi__errpuc("bad BMP", "bad BMP"); + info->mr = stbi__get32le(s); + info->mg = stbi__get32le(s); + info->mb = stbi__get32le(s); + info->ma = stbi__get32le(s); + stbi__get32le(s); // discard color space + for (i=0; i < 12; ++i) + stbi__get32le(s); // discard color space parameters + if (hsz == 124) { + stbi__get32le(s); // discard rendering intent + stbi__get32le(s); // discard offset of profile data + stbi__get32le(s); // discard size of profile data + stbi__get32le(s); // discard reserved + } + } + } + return (void *) 1; +} + + +static void *stbi__bmp_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +{ + stbi_uc *out; + unsigned int mr=0,mg=0,mb=0,ma=0, all_a; + stbi_uc pal[256][4]; + int psize=0,i,j,width; + int flip_vertically, pad, target; + stbi__bmp_data info; + STBI_NOTUSED(ri); + + info.all_a = 255; + if (stbi__bmp_parse_header(s, &info) == NULL) + return NULL; // error code already set + + flip_vertically = ((int) s->img_y) > 0; + s->img_y = abs((int) s->img_y); + + if (s->img_y > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + if (s->img_x > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + + mr = info.mr; + mg = info.mg; + mb = info.mb; + ma = info.ma; + all_a = info.all_a; + + if (info.hsz == 12) { + if (info.bpp < 24) + psize = (info.offset - info.extra_read - 24) / 3; + } else { + if (info.bpp < 16) + psize = (info.offset - info.extra_read - info.hsz) >> 2; + } + if (psize == 0) { + STBI_ASSERT(info.offset == s->callback_already_read + (int) (s->img_buffer - s->img_buffer_original)); + if (info.offset != s->callback_already_read + (s->img_buffer - s->buffer_start)) { + return stbi__errpuc("bad offset", "Corrupt BMP"); + } + } + + if (info.bpp == 24 && ma == 0xff000000) + s->img_n = 3; + else + s->img_n = ma ? 4 : 3; + if (req_comp && req_comp >= 3) // we can directly decode 3 or 4 + target = req_comp; + else + target = s->img_n; // if they want monochrome, we'll post-convert + + // sanity-check size + if (!stbi__mad3sizes_valid(target, s->img_x, s->img_y, 0)) + return stbi__errpuc("too large", "Corrupt BMP"); + + out = (stbi_uc *) stbi__malloc_mad3(target, s->img_x, s->img_y, 0); + if (!out) return stbi__errpuc("outofmem", "Out of memory"); + if (info.bpp < 16) { + int z=0; + if (psize == 0 || psize > 256) { STBI_FREE(out); return stbi__errpuc("invalid", "Corrupt BMP"); } + for (i=0; i < psize; ++i) { + pal[i][2] = stbi__get8(s); + pal[i][1] = stbi__get8(s); + pal[i][0] = stbi__get8(s); + if (info.hsz != 12) stbi__get8(s); + pal[i][3] = 255; + } + stbi__skip(s, info.offset - info.extra_read - info.hsz - psize * (info.hsz == 12 ? 3 : 4)); + if (info.bpp == 1) width = (s->img_x + 7) >> 3; + else if (info.bpp == 4) width = (s->img_x + 1) >> 1; + else if (info.bpp == 8) width = s->img_x; + else { STBI_FREE(out); return stbi__errpuc("bad bpp", "Corrupt BMP"); } + pad = (-width)&3; + if (info.bpp == 1) { + for (j=0; j < (int) s->img_y; ++j) { + int bit_offset = 7, v = stbi__get8(s); + for (i=0; i < (int) s->img_x; ++i) { + int color = (v>>bit_offset)&0x1; + out[z++] = pal[color][0]; + out[z++] = pal[color][1]; + out[z++] = pal[color][2]; + if (target == 4) out[z++] = 255; + if (i+1 == (int) s->img_x) break; + if((--bit_offset) < 0) { + bit_offset = 7; + v = stbi__get8(s); + } + } + stbi__skip(s, pad); + } + } else { + for (j=0; j < (int) s->img_y; ++j) { + for (i=0; i < (int) s->img_x; i += 2) { + int v=stbi__get8(s),v2=0; + if (info.bpp == 4) { + v2 = v & 15; + v >>= 4; + } + out[z++] = pal[v][0]; + out[z++] = pal[v][1]; + out[z++] = pal[v][2]; + if (target == 4) out[z++] = 255; + if (i+1 == (int) s->img_x) break; + v = (info.bpp == 8) ? stbi__get8(s) : v2; + out[z++] = pal[v][0]; + out[z++] = pal[v][1]; + out[z++] = pal[v][2]; + if (target == 4) out[z++] = 255; + } + stbi__skip(s, pad); + } + } + } else { + int rshift=0,gshift=0,bshift=0,ashift=0,rcount=0,gcount=0,bcount=0,acount=0; + int z = 0; + int easy=0; + stbi__skip(s, info.offset - info.extra_read - info.hsz); + if (info.bpp == 24) width = 3 * s->img_x; + else if (info.bpp == 16) width = 2*s->img_x; + else /* bpp = 32 and pad = 0 */ width=0; + pad = (-width) & 3; + if (info.bpp == 24) { + easy = 1; + } else if (info.bpp == 32) { + if (mb == 0xff && mg == 0xff00 && mr == 0x00ff0000 && ma == 0xff000000) + easy = 2; + } + if (!easy) { + if (!mr || !mg || !mb) { STBI_FREE(out); return stbi__errpuc("bad masks", "Corrupt BMP"); } + // right shift amt to put high bit in position #7 + rshift = stbi__high_bit(mr)-7; rcount = stbi__bitcount(mr); + gshift = stbi__high_bit(mg)-7; gcount = stbi__bitcount(mg); + bshift = stbi__high_bit(mb)-7; bcount = stbi__bitcount(mb); + ashift = stbi__high_bit(ma)-7; acount = stbi__bitcount(ma); + if (rcount > 8 || gcount > 8 || bcount > 8 || acount > 8) { STBI_FREE(out); return stbi__errpuc("bad masks", "Corrupt BMP"); } + } + for (j=0; j < (int) s->img_y; ++j) { + if (easy) { + for (i=0; i < (int) s->img_x; ++i) { + unsigned char a; + out[z+2] = stbi__get8(s); + out[z+1] = stbi__get8(s); + out[z+0] = stbi__get8(s); + z += 3; + a = (easy == 2 ? stbi__get8(s) : 255); + all_a |= a; + if (target == 4) out[z++] = a; + } + } else { + int bpp = info.bpp; + for (i=0; i < (int) s->img_x; ++i) { + stbi__uint32 v = (bpp == 16 ? (stbi__uint32) stbi__get16le(s) : stbi__get32le(s)); + unsigned int a; + out[z++] = STBI__BYTECAST(stbi__shiftsigned(v & mr, rshift, rcount)); + out[z++] = STBI__BYTECAST(stbi__shiftsigned(v & mg, gshift, gcount)); + out[z++] = STBI__BYTECAST(stbi__shiftsigned(v & mb, bshift, bcount)); + a = (ma ? stbi__shiftsigned(v & ma, ashift, acount) : 255); + all_a |= a; + if (target == 4) out[z++] = STBI__BYTECAST(a); + } + } + stbi__skip(s, pad); + } + } + + // if alpha channel is all 0s, replace with all 255s + if (target == 4 && all_a == 0) + for (i=4*s->img_x*s->img_y-1; i >= 0; i -= 4) + out[i] = 255; + + if (flip_vertically) { + stbi_uc t; + for (j=0; j < (int) s->img_y>>1; ++j) { + stbi_uc *p1 = out + j *s->img_x*target; + stbi_uc *p2 = out + (s->img_y-1-j)*s->img_x*target; + for (i=0; i < (int) s->img_x*target; ++i) { + t = p1[i]; p1[i] = p2[i]; p2[i] = t; + } + } + } + + if (req_comp && req_comp != target) { + out = stbi__convert_format(out, target, req_comp, s->img_x, s->img_y); + if (out == NULL) return out; // stbi__convert_format frees input on failure + } + + *x = s->img_x; + *y = s->img_y; + if (comp) *comp = s->img_n; + return out; +} +#endif + +// Targa Truevision - TGA +// by Jonathan Dummer +#ifndef STBI_NO_TGA +// returns STBI_rgb or whatever, 0 on error +static int stbi__tga_get_comp(int bits_per_pixel, int is_grey, int* is_rgb16) +{ + // only RGB or RGBA (incl. 16bit) or grey allowed + if (is_rgb16) *is_rgb16 = 0; + switch(bits_per_pixel) { + case 8: return STBI_grey; + case 16: if(is_grey) return STBI_grey_alpha; + // fallthrough + case 15: if(is_rgb16) *is_rgb16 = 1; + return STBI_rgb; + case 24: // fallthrough + case 32: return bits_per_pixel/8; + default: return 0; + } +} + +static int stbi__tga_info(stbi__context *s, int *x, int *y, int *comp) +{ + int tga_w, tga_h, tga_comp, tga_image_type, tga_bits_per_pixel, tga_colormap_bpp; + int sz, tga_colormap_type; + stbi__get8(s); // discard Offset + tga_colormap_type = stbi__get8(s); // colormap type + if( tga_colormap_type > 1 ) { + stbi__rewind(s); + return 0; // only RGB or indexed allowed + } + tga_image_type = stbi__get8(s); // image type + if ( tga_colormap_type == 1 ) { // colormapped (paletted) image + if (tga_image_type != 1 && tga_image_type != 9) { + stbi__rewind(s); + return 0; + } + stbi__skip(s,4); // skip index of first colormap entry and number of entries + sz = stbi__get8(s); // check bits per palette color entry + if ( (sz != 8) && (sz != 15) && (sz != 16) && (sz != 24) && (sz != 32) ) { + stbi__rewind(s); + return 0; + } + stbi__skip(s,4); // skip image x and y origin + tga_colormap_bpp = sz; + } else { // "normal" image w/o colormap - only RGB or grey allowed, +/- RLE + if ( (tga_image_type != 2) && (tga_image_type != 3) && (tga_image_type != 10) && (tga_image_type != 11) ) { + stbi__rewind(s); + return 0; // only RGB or grey allowed, +/- RLE + } + stbi__skip(s,9); // skip colormap specification and image x/y origin + tga_colormap_bpp = 0; + } + tga_w = stbi__get16le(s); + if( tga_w < 1 ) { + stbi__rewind(s); + return 0; // test width + } + tga_h = stbi__get16le(s); + if( tga_h < 1 ) { + stbi__rewind(s); + return 0; // test height + } + tga_bits_per_pixel = stbi__get8(s); // bits per pixel + stbi__get8(s); // ignore alpha bits + if (tga_colormap_bpp != 0) { + if((tga_bits_per_pixel != 8) && (tga_bits_per_pixel != 16)) { + // when using a colormap, tga_bits_per_pixel is the size of the indexes + // I don't think anything but 8 or 16bit indexes makes sense + stbi__rewind(s); + return 0; + } + tga_comp = stbi__tga_get_comp(tga_colormap_bpp, 0, NULL); + } else { + tga_comp = stbi__tga_get_comp(tga_bits_per_pixel, (tga_image_type == 3) || (tga_image_type == 11), NULL); + } + if(!tga_comp) { + stbi__rewind(s); + return 0; + } + if (x) *x = tga_w; + if (y) *y = tga_h; + if (comp) *comp = tga_comp; + return 1; // seems to have passed everything +} + +static int stbi__tga_test(stbi__context *s) +{ + int res = 0; + int sz, tga_color_type; + stbi__get8(s); // discard Offset + tga_color_type = stbi__get8(s); // color type + if ( tga_color_type > 1 ) goto errorEnd; // only RGB or indexed allowed + sz = stbi__get8(s); // image type + if ( tga_color_type == 1 ) { // colormapped (paletted) image + if (sz != 1 && sz != 9) goto errorEnd; // colortype 1 demands image type 1 or 9 + stbi__skip(s,4); // skip index of first colormap entry and number of entries + sz = stbi__get8(s); // check bits per palette color entry + if ( (sz != 8) && (sz != 15) && (sz != 16) && (sz != 24) && (sz != 32) ) goto errorEnd; + stbi__skip(s,4); // skip image x and y origin + } else { // "normal" image w/o colormap + if ( (sz != 2) && (sz != 3) && (sz != 10) && (sz != 11) ) goto errorEnd; // only RGB or grey allowed, +/- RLE + stbi__skip(s,9); // skip colormap specification and image x/y origin + } + if ( stbi__get16le(s) < 1 ) goto errorEnd; // test width + if ( stbi__get16le(s) < 1 ) goto errorEnd; // test height + sz = stbi__get8(s); // bits per pixel + if ( (tga_color_type == 1) && (sz != 8) && (sz != 16) ) goto errorEnd; // for colormapped images, bpp is size of an index + if ( (sz != 8) && (sz != 15) && (sz != 16) && (sz != 24) && (sz != 32) ) goto errorEnd; + + res = 1; // if we got this far, everything's good and we can return 1 instead of 0 + +errorEnd: + stbi__rewind(s); + return res; +} + +// read 16bit value and convert to 24bit RGB +static void stbi__tga_read_rgb16(stbi__context *s, stbi_uc* out) +{ + stbi__uint16 px = (stbi__uint16)stbi__get16le(s); + stbi__uint16 fiveBitMask = 31; + // we have 3 channels with 5bits each + int r = (px >> 10) & fiveBitMask; + int g = (px >> 5) & fiveBitMask; + int b = px & fiveBitMask; + // Note that this saves the data in RGB(A) order, so it doesn't need to be swapped later + out[0] = (stbi_uc)((r * 255)/31); + out[1] = (stbi_uc)((g * 255)/31); + out[2] = (stbi_uc)((b * 255)/31); + + // some people claim that the most significant bit might be used for alpha + // (possibly if an alpha-bit is set in the "image descriptor byte") + // but that only made 16bit test images completely translucent.. + // so let's treat all 15 and 16bit TGAs as RGB with no alpha. +} + +static void *stbi__tga_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +{ + // read in the TGA header stuff + int tga_offset = stbi__get8(s); + int tga_indexed = stbi__get8(s); + int tga_image_type = stbi__get8(s); + int tga_is_RLE = 0; + int tga_palette_start = stbi__get16le(s); + int tga_palette_len = stbi__get16le(s); + int tga_palette_bits = stbi__get8(s); + int tga_x_origin = stbi__get16le(s); + int tga_y_origin = stbi__get16le(s); + int tga_width = stbi__get16le(s); + int tga_height = stbi__get16le(s); + int tga_bits_per_pixel = stbi__get8(s); + int tga_comp, tga_rgb16=0; + int tga_inverted = stbi__get8(s); + // int tga_alpha_bits = tga_inverted & 15; // the 4 lowest bits - unused (useless?) + // image data + unsigned char *tga_data; + unsigned char *tga_palette = NULL; + int i, j; + unsigned char raw_data[4] = {0}; + int RLE_count = 0; + int RLE_repeating = 0; + int read_next_pixel = 1; + STBI_NOTUSED(ri); + STBI_NOTUSED(tga_x_origin); // @TODO + STBI_NOTUSED(tga_y_origin); // @TODO + + if (tga_height > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + if (tga_width > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + + // do a tiny bit of precessing + if ( tga_image_type >= 8 ) + { + tga_image_type -= 8; + tga_is_RLE = 1; + } + tga_inverted = 1 - ((tga_inverted >> 5) & 1); + + // If I'm paletted, then I'll use the number of bits from the palette + if ( tga_indexed ) tga_comp = stbi__tga_get_comp(tga_palette_bits, 0, &tga_rgb16); + else tga_comp = stbi__tga_get_comp(tga_bits_per_pixel, (tga_image_type == 3), &tga_rgb16); + + if(!tga_comp) // shouldn't really happen, stbi__tga_test() should have ensured basic consistency + return stbi__errpuc("bad format", "Can't find out TGA pixelformat"); + + // tga info + *x = tga_width; + *y = tga_height; + if (comp) *comp = tga_comp; + + if (!stbi__mad3sizes_valid(tga_width, tga_height, tga_comp, 0)) + return stbi__errpuc("too large", "Corrupt TGA"); + + tga_data = (unsigned char*)stbi__malloc_mad3(tga_width, tga_height, tga_comp, 0); + if (!tga_data) return stbi__errpuc("outofmem", "Out of memory"); + + // skip to the data's starting position (offset usually = 0) + stbi__skip(s, tga_offset ); + + if ( !tga_indexed && !tga_is_RLE && !tga_rgb16 ) { + for (i=0; i < tga_height; ++i) { + int row = tga_inverted ? tga_height -i - 1 : i; + stbi_uc *tga_row = tga_data + row*tga_width*tga_comp; + stbi__getn(s, tga_row, tga_width * tga_comp); + } + } else { + // do I need to load a palette? + if ( tga_indexed) + { + if (tga_palette_len == 0) { /* you have to have at least one entry! */ + STBI_FREE(tga_data); + return stbi__errpuc("bad palette", "Corrupt TGA"); + } + + // any data to skip? (offset usually = 0) + stbi__skip(s, tga_palette_start ); + // load the palette + tga_palette = (unsigned char*)stbi__malloc_mad2(tga_palette_len, tga_comp, 0); + if (!tga_palette) { + STBI_FREE(tga_data); + return stbi__errpuc("outofmem", "Out of memory"); + } + if (tga_rgb16) { + stbi_uc *pal_entry = tga_palette; + STBI_ASSERT(tga_comp == STBI_rgb); + for (i=0; i < tga_palette_len; ++i) { + stbi__tga_read_rgb16(s, pal_entry); + pal_entry += tga_comp; + } + } else if (!stbi__getn(s, tga_palette, tga_palette_len * tga_comp)) { + STBI_FREE(tga_data); + STBI_FREE(tga_palette); + return stbi__errpuc("bad palette", "Corrupt TGA"); + } + } + // load the data + for (i=0; i < tga_width * tga_height; ++i) + { + // if I'm in RLE mode, do I need to get a RLE stbi__pngchunk? + if ( tga_is_RLE ) + { + if ( RLE_count == 0 ) + { + // yep, get the next byte as a RLE command + int RLE_cmd = stbi__get8(s); + RLE_count = 1 + (RLE_cmd & 127); + RLE_repeating = RLE_cmd >> 7; + read_next_pixel = 1; + } else if ( !RLE_repeating ) + { + read_next_pixel = 1; + } + } else + { + read_next_pixel = 1; + } + // OK, if I need to read a pixel, do it now + if ( read_next_pixel ) + { + // load however much data we did have + if ( tga_indexed ) + { + // read in index, then perform the lookup + int pal_idx = (tga_bits_per_pixel == 8) ? stbi__get8(s) : stbi__get16le(s); + if ( pal_idx >= tga_palette_len ) { + // invalid index + pal_idx = 0; + } + pal_idx *= tga_comp; + for (j = 0; j < tga_comp; ++j) { + raw_data[j] = tga_palette[pal_idx+j]; + } + } else if(tga_rgb16) { + STBI_ASSERT(tga_comp == STBI_rgb); + stbi__tga_read_rgb16(s, raw_data); + } else { + // read in the data raw + for (j = 0; j < tga_comp; ++j) { + raw_data[j] = stbi__get8(s); + } + } + // clear the reading flag for the next pixel + read_next_pixel = 0; + } // end of reading a pixel + + // copy data + for (j = 0; j < tga_comp; ++j) + tga_data[i*tga_comp+j] = raw_data[j]; + + // in case we're in RLE mode, keep counting down + --RLE_count; + } + // do I need to invert the image? + if ( tga_inverted ) + { + for (j = 0; j*2 < tga_height; ++j) + { + int index1 = j * tga_width * tga_comp; + int index2 = (tga_height - 1 - j) * tga_width * tga_comp; + for (i = tga_width * tga_comp; i > 0; --i) + { + unsigned char temp = tga_data[index1]; + tga_data[index1] = tga_data[index2]; + tga_data[index2] = temp; + ++index1; + ++index2; + } + } + } + // clear my palette, if I had one + if ( tga_palette != NULL ) + { + STBI_FREE( tga_palette ); + } + } + + // swap RGB - if the source data was RGB16, it already is in the right order + if (tga_comp >= 3 && !tga_rgb16) + { + unsigned char* tga_pixel = tga_data; + for (i=0; i < tga_width * tga_height; ++i) + { + unsigned char temp = tga_pixel[0]; + tga_pixel[0] = tga_pixel[2]; + tga_pixel[2] = temp; + tga_pixel += tga_comp; + } + } + + // convert to target component count + if (req_comp && req_comp != tga_comp) + tga_data = stbi__convert_format(tga_data, tga_comp, req_comp, tga_width, tga_height); + + // the things I do to get rid of an error message, and yet keep + // Microsoft's C compilers happy... [8^( + tga_palette_start = tga_palette_len = tga_palette_bits = + tga_x_origin = tga_y_origin = 0; + STBI_NOTUSED(tga_palette_start); + // OK, done + return tga_data; +} +#endif + +// ************************************************************************************************* +// Photoshop PSD loader -- PD by Thatcher Ulrich, integration by Nicolas Schulz, tweaked by STB + +#ifndef STBI_NO_PSD +static int stbi__psd_test(stbi__context *s) +{ + int r = (stbi__get32be(s) == 0x38425053); + stbi__rewind(s); + return r; +} + +static int stbi__psd_decode_rle(stbi__context *s, stbi_uc *p, int pixelCount) +{ + int count, nleft, len; + + count = 0; + while ((nleft = pixelCount - count) > 0) { + len = stbi__get8(s); + if (len == 128) { + // No-op. + } else if (len < 128) { + // Copy next len+1 bytes literally. + len++; + if (len > nleft) return 0; // corrupt data + count += len; + while (len) { + *p = stbi__get8(s); + p += 4; + len--; + } + } else if (len > 128) { + stbi_uc val; + // Next -len+1 bytes in the dest are replicated from next source byte. + // (Interpret len as a negative 8-bit int.) + len = 257 - len; + if (len > nleft) return 0; // corrupt data + val = stbi__get8(s); + count += len; + while (len) { + *p = val; + p += 4; + len--; + } + } + } + + return 1; +} + +static void *stbi__psd_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri, int bpc) +{ + int pixelCount; + int channelCount, compression; + int channel, i; + int bitdepth; + int w,h; + stbi_uc *out; + STBI_NOTUSED(ri); + + // Check identifier + if (stbi__get32be(s) != 0x38425053) // "8BPS" + return stbi__errpuc("not PSD", "Corrupt PSD image"); + + // Check file type version. + if (stbi__get16be(s) != 1) + return stbi__errpuc("wrong version", "Unsupported version of PSD image"); + + // Skip 6 reserved bytes. + stbi__skip(s, 6 ); + + // Read the number of channels (R, G, B, A, etc). + channelCount = stbi__get16be(s); + if (channelCount < 0 || channelCount > 16) + return stbi__errpuc("wrong channel count", "Unsupported number of channels in PSD image"); + + // Read the rows and columns of the image. + h = stbi__get32be(s); + w = stbi__get32be(s); + + if (h > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + if (w > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + + // Make sure the depth is 8 bits. + bitdepth = stbi__get16be(s); + if (bitdepth != 8 && bitdepth != 16) + return stbi__errpuc("unsupported bit depth", "PSD bit depth is not 8 or 16 bit"); + + // Make sure the color mode is RGB. + // Valid options are: + // 0: Bitmap + // 1: Grayscale + // 2: Indexed color + // 3: RGB color + // 4: CMYK color + // 7: Multichannel + // 8: Duotone + // 9: Lab color + if (stbi__get16be(s) != 3) + return stbi__errpuc("wrong color format", "PSD is not in RGB color format"); + + // Skip the Mode Data. (It's the palette for indexed color; other info for other modes.) + stbi__skip(s,stbi__get32be(s) ); + + // Skip the image resources. (resolution, pen tool paths, etc) + stbi__skip(s, stbi__get32be(s) ); + + // Skip the reserved data. + stbi__skip(s, stbi__get32be(s) ); + + // Find out if the data is compressed. + // Known values: + // 0: no compression + // 1: RLE compressed + compression = stbi__get16be(s); + if (compression > 1) + return stbi__errpuc("bad compression", "PSD has an unknown compression format"); + + // Check size + if (!stbi__mad3sizes_valid(4, w, h, 0)) + return stbi__errpuc("too large", "Corrupt PSD"); + + // Create the destination image. + + if (!compression && bitdepth == 16 && bpc == 16) { + out = (stbi_uc *) stbi__malloc_mad3(8, w, h, 0); + ri->bits_per_channel = 16; + } else + out = (stbi_uc *) stbi__malloc(4 * w*h); + + if (!out) return stbi__errpuc("outofmem", "Out of memory"); + pixelCount = w*h; + + // Initialize the data to zero. + //memset( out, 0, pixelCount * 4 ); + + // Finally, the image data. + if (compression) { + // RLE as used by .PSD and .TIFF + // Loop until you get the number of unpacked bytes you are expecting: + // Read the next source byte into n. + // If n is between 0 and 127 inclusive, copy the next n+1 bytes literally. + // Else if n is between -127 and -1 inclusive, copy the next byte -n+1 times. + // Else if n is 128, noop. + // Endloop + + // The RLE-compressed data is preceded by a 2-byte data count for each row in the data, + // which we're going to just skip. + stbi__skip(s, h * channelCount * 2 ); + + // Read the RLE data by channel. + for (channel = 0; channel < 4; channel++) { + stbi_uc *p; + + p = out+channel; + if (channel >= channelCount) { + // Fill this channel with default data. + for (i = 0; i < pixelCount; i++, p += 4) + *p = (channel == 3 ? 255 : 0); + } else { + // Read the RLE data. + if (!stbi__psd_decode_rle(s, p, pixelCount)) { + STBI_FREE(out); + return stbi__errpuc("corrupt", "bad RLE data"); + } + } + } + + } else { + // We're at the raw image data. It's each channel in order (Red, Green, Blue, Alpha, ...) + // where each channel consists of an 8-bit (or 16-bit) value for each pixel in the image. + + // Read the data by channel. + for (channel = 0; channel < 4; channel++) { + if (channel >= channelCount) { + // Fill this channel with default data. + if (bitdepth == 16 && bpc == 16) { + stbi__uint16 *q = ((stbi__uint16 *) out) + channel; + stbi__uint16 val = channel == 3 ? 65535 : 0; + for (i = 0; i < pixelCount; i++, q += 4) + *q = val; + } else { + stbi_uc *p = out+channel; + stbi_uc val = channel == 3 ? 255 : 0; + for (i = 0; i < pixelCount; i++, p += 4) + *p = val; + } + } else { + if (ri->bits_per_channel == 16) { // output bpc + stbi__uint16 *q = ((stbi__uint16 *) out) + channel; + for (i = 0; i < pixelCount; i++, q += 4) + *q = (stbi__uint16) stbi__get16be(s); + } else { + stbi_uc *p = out+channel; + if (bitdepth == 16) { // input bpc + for (i = 0; i < pixelCount; i++, p += 4) + *p = (stbi_uc) (stbi__get16be(s) >> 8); + } else { + for (i = 0; i < pixelCount; i++, p += 4) + *p = stbi__get8(s); + } + } + } + } + } + + // remove weird white matte from PSD + if (channelCount >= 4) { + if (ri->bits_per_channel == 16) { + for (i=0; i < w*h; ++i) { + stbi__uint16 *pixel = (stbi__uint16 *) out + 4*i; + if (pixel[3] != 0 && pixel[3] != 65535) { + float a = pixel[3] / 65535.0f; + float ra = 1.0f / a; + float inv_a = 65535.0f * (1 - ra); + pixel[0] = (stbi__uint16) (pixel[0]*ra + inv_a); + pixel[1] = (stbi__uint16) (pixel[1]*ra + inv_a); + pixel[2] = (stbi__uint16) (pixel[2]*ra + inv_a); + } + } + } else { + for (i=0; i < w*h; ++i) { + unsigned char *pixel = out + 4*i; + if (pixel[3] != 0 && pixel[3] != 255) { + float a = pixel[3] / 255.0f; + float ra = 1.0f / a; + float inv_a = 255.0f * (1 - ra); + pixel[0] = (unsigned char) (pixel[0]*ra + inv_a); + pixel[1] = (unsigned char) (pixel[1]*ra + inv_a); + pixel[2] = (unsigned char) (pixel[2]*ra + inv_a); + } + } + } + } + + // convert to desired output format + if (req_comp && req_comp != 4) { + if (ri->bits_per_channel == 16) + out = (stbi_uc *) stbi__convert_format16((stbi__uint16 *) out, 4, req_comp, w, h); + else + out = stbi__convert_format(out, 4, req_comp, w, h); + if (out == NULL) return out; // stbi__convert_format frees input on failure + } + + if (comp) *comp = 4; + *y = h; + *x = w; + + return out; +} +#endif + +// ************************************************************************************************* +// Softimage PIC loader +// by Tom Seddon +// +// See http://softimage.wiki.softimage.com/index.php/INFO:_PIC_file_format +// See http://ozviz.wasp.uwa.edu.au/~pbourke/dataformats/softimagepic/ + +#ifndef STBI_NO_PIC +static int stbi__pic_is4(stbi__context *s,const char *str) +{ + int i; + for (i=0; i<4; ++i) + if (stbi__get8(s) != (stbi_uc)str[i]) + return 0; + + return 1; +} + +static int stbi__pic_test_core(stbi__context *s) +{ + int i; + + if (!stbi__pic_is4(s,"\x53\x80\xF6\x34")) + return 0; + + for(i=0;i<84;++i) + stbi__get8(s); + + if (!stbi__pic_is4(s,"PICT")) + return 0; + + return 1; +} + +typedef struct +{ + stbi_uc size,type,channel; +} stbi__pic_packet; + +static stbi_uc *stbi__readval(stbi__context *s, int channel, stbi_uc *dest) +{ + int mask=0x80, i; + + for (i=0; i<4; ++i, mask>>=1) { + if (channel & mask) { + if (stbi__at_eof(s)) return stbi__errpuc("bad file","PIC file too short"); + dest[i]=stbi__get8(s); + } + } + + return dest; +} + +static void stbi__copyval(int channel,stbi_uc *dest,const stbi_uc *src) +{ + int mask=0x80,i; + + for (i=0;i<4; ++i, mask>>=1) + if (channel&mask) + dest[i]=src[i]; +} + +static stbi_uc *stbi__pic_load_core(stbi__context *s,int width,int height,int *comp, stbi_uc *result) +{ + int act_comp=0,num_packets=0,y,chained; + stbi__pic_packet packets[10]; + + // this will (should...) cater for even some bizarre stuff like having data + // for the same channel in multiple packets. + do { + stbi__pic_packet *packet; + + if (num_packets==sizeof(packets)/sizeof(packets[0])) + return stbi__errpuc("bad format","too many packets"); + + packet = &packets[num_packets++]; + + chained = stbi__get8(s); + packet->size = stbi__get8(s); + packet->type = stbi__get8(s); + packet->channel = stbi__get8(s); + + act_comp |= packet->channel; + + if (stbi__at_eof(s)) return stbi__errpuc("bad file","file too short (reading packets)"); + if (packet->size != 8) return stbi__errpuc("bad format","packet isn't 8bpp"); + } while (chained); + + *comp = (act_comp & 0x10 ? 4 : 3); // has alpha channel? + + for(y=0; ytype) { + default: + return stbi__errpuc("bad format","packet has bad compression type"); + + case 0: {//uncompressed + int x; + + for(x=0;xchannel,dest)) + return 0; + break; + } + + case 1://Pure RLE + { + int left=width, i; + + while (left>0) { + stbi_uc count,value[4]; + + count=stbi__get8(s); + if (stbi__at_eof(s)) return stbi__errpuc("bad file","file too short (pure read count)"); + + if (count > left) + count = (stbi_uc) left; + + if (!stbi__readval(s,packet->channel,value)) return 0; + + for(i=0; ichannel,dest,value); + left -= count; + } + } + break; + + case 2: {//Mixed RLE + int left=width; + while (left>0) { + int count = stbi__get8(s), i; + if (stbi__at_eof(s)) return stbi__errpuc("bad file","file too short (mixed read count)"); + + if (count >= 128) { // Repeated + stbi_uc value[4]; + + if (count==128) + count = stbi__get16be(s); + else + count -= 127; + if (count > left) + return stbi__errpuc("bad file","scanline overrun"); + + if (!stbi__readval(s,packet->channel,value)) + return 0; + + for(i=0;ichannel,dest,value); + } else { // Raw + ++count; + if (count>left) return stbi__errpuc("bad file","scanline overrun"); + + for(i=0;ichannel,dest)) + return 0; + } + left-=count; + } + break; + } + } + } + } + + return result; +} + +static void *stbi__pic_load(stbi__context *s,int *px,int *py,int *comp,int req_comp, stbi__result_info *ri) +{ + stbi_uc *result; + int i, x,y, internal_comp; + STBI_NOTUSED(ri); + + if (!comp) comp = &internal_comp; + + for (i=0; i<92; ++i) + stbi__get8(s); + + x = stbi__get16be(s); + y = stbi__get16be(s); + + if (y > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + if (x > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + + if (stbi__at_eof(s)) return stbi__errpuc("bad file","file too short (pic header)"); + if (!stbi__mad3sizes_valid(x, y, 4, 0)) return stbi__errpuc("too large", "PIC image too large to decode"); + + stbi__get32be(s); //skip `ratio' + stbi__get16be(s); //skip `fields' + stbi__get16be(s); //skip `pad' + + // intermediate buffer is RGBA + result = (stbi_uc *) stbi__malloc_mad3(x, y, 4, 0); + memset(result, 0xff, x*y*4); + + if (!stbi__pic_load_core(s,x,y,comp, result)) { + STBI_FREE(result); + result=0; + } + *px = x; + *py = y; + if (req_comp == 0) req_comp = *comp; + result=stbi__convert_format(result,4,req_comp,x,y); + + return result; +} + +static int stbi__pic_test(stbi__context *s) +{ + int r = stbi__pic_test_core(s); + stbi__rewind(s); + return r; +} +#endif + +// ************************************************************************************************* +// GIF loader -- public domain by Jean-Marc Lienher -- simplified/shrunk by stb + +#ifndef STBI_NO_GIF +typedef struct +{ + stbi__int16 prefix; + stbi_uc first; + stbi_uc suffix; +} stbi__gif_lzw; + +typedef struct +{ + int w,h; + stbi_uc *out; // output buffer (always 4 components) + stbi_uc *background; // The current "background" as far as a gif is concerned + stbi_uc *history; + int flags, bgindex, ratio, transparent, eflags; + stbi_uc pal[256][4]; + stbi_uc lpal[256][4]; + stbi__gif_lzw codes[8192]; + stbi_uc *color_table; + int parse, step; + int lflags; + int start_x, start_y; + int max_x, max_y; + int cur_x, cur_y; + int line_size; + int delay; +} stbi__gif; + +static int stbi__gif_test_raw(stbi__context *s) +{ + int sz; + if (stbi__get8(s) != 'G' || stbi__get8(s) != 'I' || stbi__get8(s) != 'F' || stbi__get8(s) != '8') return 0; + sz = stbi__get8(s); + if (sz != '9' && sz != '7') return 0; + if (stbi__get8(s) != 'a') return 0; + return 1; +} + +static int stbi__gif_test(stbi__context *s) +{ + int r = stbi__gif_test_raw(s); + stbi__rewind(s); + return r; +} + +static void stbi__gif_parse_colortable(stbi__context *s, stbi_uc pal[256][4], int num_entries, int transp) +{ + int i; + for (i=0; i < num_entries; ++i) { + pal[i][2] = stbi__get8(s); + pal[i][1] = stbi__get8(s); + pal[i][0] = stbi__get8(s); + pal[i][3] = transp == i ? 0 : 255; + } +} + +static int stbi__gif_header(stbi__context *s, stbi__gif *g, int *comp, int is_info) +{ + stbi_uc version; + if (stbi__get8(s) != 'G' || stbi__get8(s) != 'I' || stbi__get8(s) != 'F' || stbi__get8(s) != '8') + return stbi__err("not GIF", "Corrupt GIF"); + + version = stbi__get8(s); + if (version != '7' && version != '9') return stbi__err("not GIF", "Corrupt GIF"); + if (stbi__get8(s) != 'a') return stbi__err("not GIF", "Corrupt GIF"); + + stbi__g_failure_reason = ""; + g->w = stbi__get16le(s); + g->h = stbi__get16le(s); + g->flags = stbi__get8(s); + g->bgindex = stbi__get8(s); + g->ratio = stbi__get8(s); + g->transparent = -1; + + if (g->w > STBI_MAX_DIMENSIONS) return stbi__err("too large","Very large image (corrupt?)"); + if (g->h > STBI_MAX_DIMENSIONS) return stbi__err("too large","Very large image (corrupt?)"); + + if (comp != 0) *comp = 4; // can't actually tell whether it's 3 or 4 until we parse the comments + + if (is_info) return 1; + + if (g->flags & 0x80) + stbi__gif_parse_colortable(s,g->pal, 2 << (g->flags & 7), -1); + + return 1; +} + +static int stbi__gif_info_raw(stbi__context *s, int *x, int *y, int *comp) +{ + stbi__gif* g = (stbi__gif*) stbi__malloc(sizeof(stbi__gif)); + if (!stbi__gif_header(s, g, comp, 1)) { + STBI_FREE(g); + stbi__rewind( s ); + return 0; + } + if (x) *x = g->w; + if (y) *y = g->h; + STBI_FREE(g); + return 1; +} + +static void stbi__out_gif_code(stbi__gif *g, stbi__uint16 code) +{ + stbi_uc *p, *c; + int idx; + + // recurse to decode the prefixes, since the linked-list is backwards, + // and working backwards through an interleaved image would be nasty + if (g->codes[code].prefix >= 0) + stbi__out_gif_code(g, g->codes[code].prefix); + + if (g->cur_y >= g->max_y) return; + + idx = g->cur_x + g->cur_y; + p = &g->out[idx]; + g->history[idx / 4] = 1; + + c = &g->color_table[g->codes[code].suffix * 4]; + if (c[3] > 128) { // don't render transparent pixels; + p[0] = c[2]; + p[1] = c[1]; + p[2] = c[0]; + p[3] = c[3]; + } + g->cur_x += 4; + + if (g->cur_x >= g->max_x) { + g->cur_x = g->start_x; + g->cur_y += g->step; + + while (g->cur_y >= g->max_y && g->parse > 0) { + g->step = (1 << g->parse) * g->line_size; + g->cur_y = g->start_y + (g->step >> 1); + --g->parse; + } + } +} + +static stbi_uc *stbi__process_gif_raster(stbi__context *s, stbi__gif *g) +{ + stbi_uc lzw_cs; + stbi__int32 len, init_code; + stbi__uint32 first; + stbi__int32 codesize, codemask, avail, oldcode, bits, valid_bits, clear; + stbi__gif_lzw *p; + + lzw_cs = stbi__get8(s); + if (lzw_cs > 12) return NULL; + clear = 1 << lzw_cs; + first = 1; + codesize = lzw_cs + 1; + codemask = (1 << codesize) - 1; + bits = 0; + valid_bits = 0; + for (init_code = 0; init_code < clear; init_code++) { + g->codes[init_code].prefix = -1; + g->codes[init_code].first = (stbi_uc) init_code; + g->codes[init_code].suffix = (stbi_uc) init_code; + } + + // support no starting clear code + avail = clear+2; + oldcode = -1; + + len = 0; + for(;;) { + if (valid_bits < codesize) { + if (len == 0) { + len = stbi__get8(s); // start new block + if (len == 0) + return g->out; + } + --len; + bits |= (stbi__int32) stbi__get8(s) << valid_bits; + valid_bits += 8; + } else { + stbi__int32 code = bits & codemask; + bits >>= codesize; + valid_bits -= codesize; + // @OPTIMIZE: is there some way we can accelerate the non-clear path? + if (code == clear) { // clear code + codesize = lzw_cs + 1; + codemask = (1 << codesize) - 1; + avail = clear + 2; + oldcode = -1; + first = 0; + } else if (code == clear + 1) { // end of stream code + stbi__skip(s, len); + while ((len = stbi__get8(s)) > 0) + stbi__skip(s,len); + return g->out; + } else if (code <= avail) { + if (first) { + return stbi__errpuc("no clear code", "Corrupt GIF"); + } + + if (oldcode >= 0) { + p = &g->codes[avail++]; + if (avail > 8192) { + return stbi__errpuc("too many codes", "Corrupt GIF"); + } + + p->prefix = (stbi__int16) oldcode; + p->first = g->codes[oldcode].first; + p->suffix = (code == avail) ? p->first : g->codes[code].first; + } else if (code == avail) + return stbi__errpuc("illegal code in raster", "Corrupt GIF"); + + stbi__out_gif_code(g, (stbi__uint16) code); + + if ((avail & codemask) == 0 && avail <= 0x0FFF) { + codesize++; + codemask = (1 << codesize) - 1; + } + + oldcode = code; + } else { + return stbi__errpuc("illegal code in raster", "Corrupt GIF"); + } + } + } +} + +// this function is designed to support animated gifs, although stb_image doesn't support it +// two back is the image from two frames ago, used for a very specific disposal format +static stbi_uc *stbi__gif_load_next(stbi__context *s, stbi__gif *g, int *comp, int req_comp, stbi_uc *two_back) +{ + int dispose; + int first_frame; + int pi; + int pcount; + STBI_NOTUSED(req_comp); + + // on first frame, any non-written pixels get the background colour (non-transparent) + first_frame = 0; + if (g->out == 0) { + if (!stbi__gif_header(s, g, comp,0)) return 0; // stbi__g_failure_reason set by stbi__gif_header + if (!stbi__mad3sizes_valid(4, g->w, g->h, 0)) + return stbi__errpuc("too large", "GIF image is too large"); + pcount = g->w * g->h; + g->out = (stbi_uc *) stbi__malloc(4 * pcount); + g->background = (stbi_uc *) stbi__malloc(4 * pcount); + g->history = (stbi_uc *) stbi__malloc(pcount); + if (!g->out || !g->background || !g->history) + return stbi__errpuc("outofmem", "Out of memory"); + + // image is treated as "transparent" at the start - ie, nothing overwrites the current background; + // background colour is only used for pixels that are not rendered first frame, after that "background" + // color refers to the color that was there the previous frame. + memset(g->out, 0x00, 4 * pcount); + memset(g->background, 0x00, 4 * pcount); // state of the background (starts transparent) + memset(g->history, 0x00, pcount); // pixels that were affected previous frame + first_frame = 1; + } else { + // second frame - how do we dispose of the previous one? + dispose = (g->eflags & 0x1C) >> 2; + pcount = g->w * g->h; + + if ((dispose == 3) && (two_back == 0)) { + dispose = 2; // if I don't have an image to revert back to, default to the old background + } + + if (dispose == 3) { // use previous graphic + for (pi = 0; pi < pcount; ++pi) { + if (g->history[pi]) { + memcpy( &g->out[pi * 4], &two_back[pi * 4], 4 ); + } + } + } else if (dispose == 2) { + // restore what was changed last frame to background before that frame; + for (pi = 0; pi < pcount; ++pi) { + if (g->history[pi]) { + memcpy( &g->out[pi * 4], &g->background[pi * 4], 4 ); + } + } + } else { + // This is a non-disposal case eithe way, so just + // leave the pixels as is, and they will become the new background + // 1: do not dispose + // 0: not specified. + } + + // background is what out is after the undoing of the previou frame; + memcpy( g->background, g->out, 4 * g->w * g->h ); + } + + // clear my history; + memset( g->history, 0x00, g->w * g->h ); // pixels that were affected previous frame + + for (;;) { + int tag = stbi__get8(s); + switch (tag) { + case 0x2C: /* Image Descriptor */ + { + stbi__int32 x, y, w, h; + stbi_uc *o; + + x = stbi__get16le(s); + y = stbi__get16le(s); + w = stbi__get16le(s); + h = stbi__get16le(s); + if (((x + w) > (g->w)) || ((y + h) > (g->h))) + return stbi__errpuc("bad Image Descriptor", "Corrupt GIF"); + + g->line_size = g->w * 4; + g->start_x = x * 4; + g->start_y = y * g->line_size; + g->max_x = g->start_x + w * 4; + g->max_y = g->start_y + h * g->line_size; + g->cur_x = g->start_x; + g->cur_y = g->start_y; + + // if the width of the specified rectangle is 0, that means + // we may not see *any* pixels or the image is malformed; + // to make sure this is caught, move the current y down to + // max_y (which is what out_gif_code checks). + if (w == 0) + g->cur_y = g->max_y; + + g->lflags = stbi__get8(s); + + if (g->lflags & 0x40) { + g->step = 8 * g->line_size; // first interlaced spacing + g->parse = 3; + } else { + g->step = g->line_size; + g->parse = 0; + } + + if (g->lflags & 0x80) { + stbi__gif_parse_colortable(s,g->lpal, 2 << (g->lflags & 7), g->eflags & 0x01 ? g->transparent : -1); + g->color_table = (stbi_uc *) g->lpal; + } else if (g->flags & 0x80) { + g->color_table = (stbi_uc *) g->pal; + } else + return stbi__errpuc("missing color table", "Corrupt GIF"); + + o = stbi__process_gif_raster(s, g); + if (!o) return NULL; + + // if this was the first frame, + pcount = g->w * g->h; + if (first_frame && (g->bgindex > 0)) { + // if first frame, any pixel not drawn to gets the background color + for (pi = 0; pi < pcount; ++pi) { + if (g->history[pi] == 0) { + g->pal[g->bgindex][3] = 255; // just in case it was made transparent, undo that; It will be reset next frame if need be; + memcpy( &g->out[pi * 4], &g->pal[g->bgindex], 4 ); + } + } + } + + return o; + } + + case 0x21: // Comment Extension. + { + int len; + int ext = stbi__get8(s); + if (ext == 0xF9) { // Graphic Control Extension. + len = stbi__get8(s); + if (len == 4) { + g->eflags = stbi__get8(s); + g->delay = 10 * stbi__get16le(s); // delay - 1/100th of a second, saving as 1/1000ths. + + // unset old transparent + if (g->transparent >= 0) { + g->pal[g->transparent][3] = 255; + } + if (g->eflags & 0x01) { + g->transparent = stbi__get8(s); + if (g->transparent >= 0) { + g->pal[g->transparent][3] = 0; + } + } else { + // don't need transparent + stbi__skip(s, 1); + g->transparent = -1; + } + } else { + stbi__skip(s, len); + break; + } + } + while ((len = stbi__get8(s)) != 0) { + stbi__skip(s, len); + } + break; + } + + case 0x3B: // gif stream termination code + return (stbi_uc *) s; // using '1' causes warning on some compilers + + default: + return stbi__errpuc("unknown code", "Corrupt GIF"); + } + } +} + +static void *stbi__load_gif_main(stbi__context *s, int **delays, int *x, int *y, int *z, int *comp, int req_comp) +{ + if (stbi__gif_test(s)) { + int layers = 0; + stbi_uc *u = 0; + stbi_uc *out = 0; + stbi_uc *two_back = 0; + stbi__gif g; + int stride; + int out_size = 0; + int delays_size = 0; + memset(&g, 0, sizeof(g)); + if (delays) { + *delays = 0; + } + + do { + u = stbi__gif_load_next(s, &g, comp, req_comp, two_back); + if (u == (stbi_uc *) s) u = 0; // end of animated gif marker + + if (u) { + *x = g.w; + *y = g.h; + ++layers; + stride = g.w * g.h * 4; + + if (out) { + void *tmp = (stbi_uc*) STBI_REALLOC_SIZED( out, out_size, layers * stride ); + if (NULL == tmp) { + STBI_FREE(g.out); + STBI_FREE(g.history); + STBI_FREE(g.background); + return stbi__errpuc("outofmem", "Out of memory"); + } + else { + out = (stbi_uc*) tmp; + out_size = layers * stride; + } + + if (delays) { + *delays = (int*) STBI_REALLOC_SIZED( *delays, delays_size, sizeof(int) * layers ); + delays_size = layers * sizeof(int); + } + } else { + out = (stbi_uc*)stbi__malloc( layers * stride ); + out_size = layers * stride; + if (delays) { + *delays = (int*) stbi__malloc( layers * sizeof(int) ); + delays_size = layers * sizeof(int); + } + } + memcpy( out + ((layers - 1) * stride), u, stride ); + if (layers >= 2) { + two_back = out - 2 * stride; + } + + if (delays) { + (*delays)[layers - 1U] = g.delay; + } + } + } while (u != 0); + + // free temp buffer; + STBI_FREE(g.out); + STBI_FREE(g.history); + STBI_FREE(g.background); + + // do the final conversion after loading everything; + if (req_comp && req_comp != 4) + out = stbi__convert_format(out, 4, req_comp, layers * g.w, g.h); + + *z = layers; + return out; + } else { + return stbi__errpuc("not GIF", "Image was not as a gif type."); + } +} + +static void *stbi__gif_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +{ + stbi_uc *u = 0; + stbi__gif g; + memset(&g, 0, sizeof(g)); + STBI_NOTUSED(ri); + + u = stbi__gif_load_next(s, &g, comp, req_comp, 0); + if (u == (stbi_uc *) s) u = 0; // end of animated gif marker + if (u) { + *x = g.w; + *y = g.h; + + // moved conversion to after successful load so that the same + // can be done for multiple frames. + if (req_comp && req_comp != 4) + u = stbi__convert_format(u, 4, req_comp, g.w, g.h); + } else if (g.out) { + // if there was an error and we allocated an image buffer, free it! + STBI_FREE(g.out); + } + + // free buffers needed for multiple frame loading; + STBI_FREE(g.history); + STBI_FREE(g.background); + + return u; +} + +static int stbi__gif_info(stbi__context *s, int *x, int *y, int *comp) +{ + return stbi__gif_info_raw(s,x,y,comp); +} +#endif + +// ************************************************************************************************* +// Radiance RGBE HDR loader +// originally by Nicolas Schulz +#ifndef STBI_NO_HDR +static int stbi__hdr_test_core(stbi__context *s, const char *signature) +{ + int i; + for (i=0; signature[i]; ++i) + if (stbi__get8(s) != signature[i]) + return 0; + stbi__rewind(s); + return 1; +} + +static int stbi__hdr_test(stbi__context* s) +{ + int r = stbi__hdr_test_core(s, "#?RADIANCE\n"); + stbi__rewind(s); + if(!r) { + r = stbi__hdr_test_core(s, "#?RGBE\n"); + stbi__rewind(s); + } + return r; +} + +#define STBI__HDR_BUFLEN 1024 +static char *stbi__hdr_gettoken(stbi__context *z, char *buffer) +{ + int len=0; + char c = '\0'; + + c = (char) stbi__get8(z); + + while (!stbi__at_eof(z) && c != '\n') { + buffer[len++] = c; + if (len == STBI__HDR_BUFLEN-1) { + // flush to end of line + while (!stbi__at_eof(z) && stbi__get8(z) != '\n') + ; + break; + } + c = (char) stbi__get8(z); + } + + buffer[len] = 0; + return buffer; +} + +static void stbi__hdr_convert(float *output, stbi_uc *input, int req_comp) +{ + if ( input[3] != 0 ) { + float f1; + // Exponent + f1 = (float) ldexp(1.0f, input[3] - (int)(128 + 8)); + if (req_comp <= 2) + output[0] = (input[0] + input[1] + input[2]) * f1 / 3; + else { + output[0] = input[0] * f1; + output[1] = input[1] * f1; + output[2] = input[2] * f1; + } + if (req_comp == 2) output[1] = 1; + if (req_comp == 4) output[3] = 1; + } else { + switch (req_comp) { + case 4: output[3] = 1; /* fallthrough */ + case 3: output[0] = output[1] = output[2] = 0; + break; + case 2: output[1] = 1; /* fallthrough */ + case 1: output[0] = 0; + break; + } + } +} + +static float *stbi__hdr_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +{ + char buffer[STBI__HDR_BUFLEN]; + char *token; + int valid = 0; + int width, height; + stbi_uc *scanline; + float *hdr_data; + int len; + unsigned char count, value; + int i, j, k, c1,c2, z; + const char *headerToken; + STBI_NOTUSED(ri); + + // Check identifier + headerToken = stbi__hdr_gettoken(s,buffer); + if (strcmp(headerToken, "#?RADIANCE") != 0 && strcmp(headerToken, "#?RGBE") != 0) + return stbi__errpf("not HDR", "Corrupt HDR image"); + + // Parse header + for(;;) { + token = stbi__hdr_gettoken(s,buffer); + if (token[0] == 0) break; + if (strcmp(token, "FORMAT=32-bit_rle_rgbe") == 0) valid = 1; + } + + if (!valid) return stbi__errpf("unsupported format", "Unsupported HDR format"); + + // Parse width and height + // can't use sscanf() if we're not using stdio! + token = stbi__hdr_gettoken(s,buffer); + if (strncmp(token, "-Y ", 3)) return stbi__errpf("unsupported data layout", "Unsupported HDR format"); + token += 3; + height = (int) strtol(token, &token, 10); + while (*token == ' ') ++token; + if (strncmp(token, "+X ", 3)) return stbi__errpf("unsupported data layout", "Unsupported HDR format"); + token += 3; + width = (int) strtol(token, NULL, 10); + + if (height > STBI_MAX_DIMENSIONS) return stbi__errpf("too large","Very large image (corrupt?)"); + if (width > STBI_MAX_DIMENSIONS) return stbi__errpf("too large","Very large image (corrupt?)"); + + *x = width; + *y = height; + + if (comp) *comp = 3; + if (req_comp == 0) req_comp = 3; + + if (!stbi__mad4sizes_valid(width, height, req_comp, sizeof(float), 0)) + return stbi__errpf("too large", "HDR image is too large"); + + // Read data + hdr_data = (float *) stbi__malloc_mad4(width, height, req_comp, sizeof(float), 0); + if (!hdr_data) + return stbi__errpf("outofmem", "Out of memory"); + + // Load image data + // image data is stored as some number of sca + if ( width < 8 || width >= 32768) { + // Read flat data + for (j=0; j < height; ++j) { + for (i=0; i < width; ++i) { + stbi_uc rgbe[4]; + main_decode_loop: + stbi__getn(s, rgbe, 4); + stbi__hdr_convert(hdr_data + j * width * req_comp + i * req_comp, rgbe, req_comp); + } + } + } else { + // Read RLE-encoded data + scanline = NULL; + + for (j = 0; j < height; ++j) { + c1 = stbi__get8(s); + c2 = stbi__get8(s); + len = stbi__get8(s); + if (c1 != 2 || c2 != 2 || (len & 0x80)) { + // not run-length encoded, so we have to actually use THIS data as a decoded + // pixel (note this can't be a valid pixel--one of RGB must be >= 128) + stbi_uc rgbe[4]; + rgbe[0] = (stbi_uc) c1; + rgbe[1] = (stbi_uc) c2; + rgbe[2] = (stbi_uc) len; + rgbe[3] = (stbi_uc) stbi__get8(s); + stbi__hdr_convert(hdr_data, rgbe, req_comp); + i = 1; + j = 0; + STBI_FREE(scanline); + goto main_decode_loop; // yes, this makes no sense + } + len <<= 8; + len |= stbi__get8(s); + if (len != width) { STBI_FREE(hdr_data); STBI_FREE(scanline); return stbi__errpf("invalid decoded scanline length", "corrupt HDR"); } + if (scanline == NULL) { + scanline = (stbi_uc *) stbi__malloc_mad2(width, 4, 0); + if (!scanline) { + STBI_FREE(hdr_data); + return stbi__errpf("outofmem", "Out of memory"); + } + } + + for (k = 0; k < 4; ++k) { + int nleft; + i = 0; + while ((nleft = width - i) > 0) { + count = stbi__get8(s); + if (count > 128) { + // Run + value = stbi__get8(s); + count -= 128; + if (count > nleft) { STBI_FREE(hdr_data); STBI_FREE(scanline); return stbi__errpf("corrupt", "bad RLE data in HDR"); } + for (z = 0; z < count; ++z) + scanline[i++ * 4 + k] = value; + } else { + // Dump + if (count > nleft) { STBI_FREE(hdr_data); STBI_FREE(scanline); return stbi__errpf("corrupt", "bad RLE data in HDR"); } + for (z = 0; z < count; ++z) + scanline[i++ * 4 + k] = stbi__get8(s); + } + } + } + for (i=0; i < width; ++i) + stbi__hdr_convert(hdr_data+(j*width + i)*req_comp, scanline + i*4, req_comp); + } + if (scanline) + STBI_FREE(scanline); + } + + return hdr_data; +} + +static int stbi__hdr_info(stbi__context *s, int *x, int *y, int *comp) +{ + char buffer[STBI__HDR_BUFLEN]; + char *token; + int valid = 0; + int dummy; + + if (!x) x = &dummy; + if (!y) y = &dummy; + if (!comp) comp = &dummy; + + if (stbi__hdr_test(s) == 0) { + stbi__rewind( s ); + return 0; + } + + for(;;) { + token = stbi__hdr_gettoken(s,buffer); + if (token[0] == 0) break; + if (strcmp(token, "FORMAT=32-bit_rle_rgbe") == 0) valid = 1; + } + + if (!valid) { + stbi__rewind( s ); + return 0; + } + token = stbi__hdr_gettoken(s,buffer); + if (strncmp(token, "-Y ", 3)) { + stbi__rewind( s ); + return 0; + } + token += 3; + *y = (int) strtol(token, &token, 10); + while (*token == ' ') ++token; + if (strncmp(token, "+X ", 3)) { + stbi__rewind( s ); + return 0; + } + token += 3; + *x = (int) strtol(token, NULL, 10); + *comp = 3; + return 1; +} +#endif // STBI_NO_HDR + +#ifndef STBI_NO_BMP +static int stbi__bmp_info(stbi__context *s, int *x, int *y, int *comp) +{ + void *p; + stbi__bmp_data info; + + info.all_a = 255; + p = stbi__bmp_parse_header(s, &info); + stbi__rewind( s ); + if (p == NULL) + return 0; + if (x) *x = s->img_x; + if (y) *y = s->img_y; + if (comp) { + if (info.bpp == 24 && info.ma == 0xff000000) + *comp = 3; + else + *comp = info.ma ? 4 : 3; + } + return 1; +} +#endif + +#ifndef STBI_NO_PSD +static int stbi__psd_info(stbi__context *s, int *x, int *y, int *comp) +{ + int channelCount, dummy, depth; + if (!x) x = &dummy; + if (!y) y = &dummy; + if (!comp) comp = &dummy; + if (stbi__get32be(s) != 0x38425053) { + stbi__rewind( s ); + return 0; + } + if (stbi__get16be(s) != 1) { + stbi__rewind( s ); + return 0; + } + stbi__skip(s, 6); + channelCount = stbi__get16be(s); + if (channelCount < 0 || channelCount > 16) { + stbi__rewind( s ); + return 0; + } + *y = stbi__get32be(s); + *x = stbi__get32be(s); + depth = stbi__get16be(s); + if (depth != 8 && depth != 16) { + stbi__rewind( s ); + return 0; + } + if (stbi__get16be(s) != 3) { + stbi__rewind( s ); + return 0; + } + *comp = 4; + return 1; +} + +static int stbi__psd_is16(stbi__context *s) +{ + int channelCount, depth; + if (stbi__get32be(s) != 0x38425053) { + stbi__rewind( s ); + return 0; + } + if (stbi__get16be(s) != 1) { + stbi__rewind( s ); + return 0; + } + stbi__skip(s, 6); + channelCount = stbi__get16be(s); + if (channelCount < 0 || channelCount > 16) { + stbi__rewind( s ); + return 0; + } + (void) stbi__get32be(s); + (void) stbi__get32be(s); + depth = stbi__get16be(s); + if (depth != 16) { + stbi__rewind( s ); + return 0; + } + return 1; +} +#endif + +#ifndef STBI_NO_PIC +static int stbi__pic_info(stbi__context *s, int *x, int *y, int *comp) +{ + int act_comp=0,num_packets=0,chained,dummy; + stbi__pic_packet packets[10]; + + if (!x) x = &dummy; + if (!y) y = &dummy; + if (!comp) comp = &dummy; + + if (!stbi__pic_is4(s,"\x53\x80\xF6\x34")) { + stbi__rewind(s); + return 0; + } + + stbi__skip(s, 88); + + *x = stbi__get16be(s); + *y = stbi__get16be(s); + if (stbi__at_eof(s)) { + stbi__rewind( s); + return 0; + } + if ( (*x) != 0 && (1 << 28) / (*x) < (*y)) { + stbi__rewind( s ); + return 0; + } + + stbi__skip(s, 8); + + do { + stbi__pic_packet *packet; + + if (num_packets==sizeof(packets)/sizeof(packets[0])) + return 0; + + packet = &packets[num_packets++]; + chained = stbi__get8(s); + packet->size = stbi__get8(s); + packet->type = stbi__get8(s); + packet->channel = stbi__get8(s); + act_comp |= packet->channel; + + if (stbi__at_eof(s)) { + stbi__rewind( s ); + return 0; + } + if (packet->size != 8) { + stbi__rewind( s ); + return 0; + } + } while (chained); + + *comp = (act_comp & 0x10 ? 4 : 3); + + return 1; +} +#endif + +// ************************************************************************************************* +// Portable Gray Map and Portable Pixel Map loader +// by Ken Miller +// +// PGM: http://netpbm.sourceforge.net/doc/pgm.html +// PPM: http://netpbm.sourceforge.net/doc/ppm.html +// +// Known limitations: +// Does not support comments in the header section +// Does not support ASCII image data (formats P2 and P3) +// Does not support 16-bit-per-channel + +#ifndef STBI_NO_PNM + +static int stbi__pnm_test(stbi__context *s) +{ + char p, t; + p = (char) stbi__get8(s); + t = (char) stbi__get8(s); + if (p != 'P' || (t != '5' && t != '6')) { + stbi__rewind( s ); + return 0; + } + return 1; +} + +static void *stbi__pnm_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +{ + stbi_uc *out; + STBI_NOTUSED(ri); + + if (!stbi__pnm_info(s, (int *)&s->img_x, (int *)&s->img_y, (int *)&s->img_n)) + return 0; + + if (s->img_y > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + if (s->img_x > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + + *x = s->img_x; + *y = s->img_y; + if (comp) *comp = s->img_n; + + if (!stbi__mad3sizes_valid(s->img_n, s->img_x, s->img_y, 0)) + return stbi__errpuc("too large", "PNM too large"); + + out = (stbi_uc *) stbi__malloc_mad3(s->img_n, s->img_x, s->img_y, 0); + if (!out) return stbi__errpuc("outofmem", "Out of memory"); + stbi__getn(s, out, s->img_n * s->img_x * s->img_y); + + if (req_comp && req_comp != s->img_n) { + out = stbi__convert_format(out, s->img_n, req_comp, s->img_x, s->img_y); + if (out == NULL) return out; // stbi__convert_format frees input on failure + } + return out; +} + +static int stbi__pnm_isspace(char c) +{ + return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r'; +} + +static void stbi__pnm_skip_whitespace(stbi__context *s, char *c) +{ + for (;;) { + while (!stbi__at_eof(s) && stbi__pnm_isspace(*c)) + *c = (char) stbi__get8(s); + + if (stbi__at_eof(s) || *c != '#') + break; + + while (!stbi__at_eof(s) && *c != '\n' && *c != '\r' ) + *c = (char) stbi__get8(s); + } +} + +static int stbi__pnm_isdigit(char c) +{ + return c >= '0' && c <= '9'; +} + +static int stbi__pnm_getinteger(stbi__context *s, char *c) +{ + int value = 0; + + while (!stbi__at_eof(s) && stbi__pnm_isdigit(*c)) { + value = value*10 + (*c - '0'); + *c = (char) stbi__get8(s); + } + + return value; +} + +static int stbi__pnm_info(stbi__context *s, int *x, int *y, int *comp) +{ + int maxv, dummy; + char c, p, t; + + if (!x) x = &dummy; + if (!y) y = &dummy; + if (!comp) comp = &dummy; + + stbi__rewind(s); + + // Get identifier + p = (char) stbi__get8(s); + t = (char) stbi__get8(s); + if (p != 'P' || (t != '5' && t != '6')) { + stbi__rewind(s); + return 0; + } + + *comp = (t == '6') ? 3 : 1; // '5' is 1-component .pgm; '6' is 3-component .ppm + + c = (char) stbi__get8(s); + stbi__pnm_skip_whitespace(s, &c); + + *x = stbi__pnm_getinteger(s, &c); // read width + stbi__pnm_skip_whitespace(s, &c); + + *y = stbi__pnm_getinteger(s, &c); // read height + stbi__pnm_skip_whitespace(s, &c); + + maxv = stbi__pnm_getinteger(s, &c); // read max value + + if (maxv > 255) + return stbi__err("max value > 255", "PPM image not 8-bit"); + else + return 1; +} +#endif + +static int stbi__info_main(stbi__context *s, int *x, int *y, int *comp) +{ + #ifndef STBI_NO_JPEG + if (stbi__jpeg_info(s, x, y, comp)) return 1; + #endif + + #ifndef STBI_NO_PNG + if (stbi__png_info(s, x, y, comp)) return 1; + #endif + + #ifndef STBI_NO_GIF + if (stbi__gif_info(s, x, y, comp)) return 1; + #endif + + #ifndef STBI_NO_BMP + if (stbi__bmp_info(s, x, y, comp)) return 1; + #endif + + #ifndef STBI_NO_PSD + if (stbi__psd_info(s, x, y, comp)) return 1; + #endif + + #ifndef STBI_NO_PIC + if (stbi__pic_info(s, x, y, comp)) return 1; + #endif + + #ifndef STBI_NO_PNM + if (stbi__pnm_info(s, x, y, comp)) return 1; + #endif + + #ifndef STBI_NO_HDR + if (stbi__hdr_info(s, x, y, comp)) return 1; + #endif + + // test tga last because it's a crappy test! + #ifndef STBI_NO_TGA + if (stbi__tga_info(s, x, y, comp)) + return 1; + #endif + return stbi__err("unknown image type", "Image not of any known type, or corrupt"); +} + +static int stbi__is_16_main(stbi__context *s) +{ + #ifndef STBI_NO_PNG + if (stbi__png_is16(s)) return 1; + #endif + + #ifndef STBI_NO_PSD + if (stbi__psd_is16(s)) return 1; + #endif + + return 0; +} + +#ifndef STBI_NO_STDIO +STBIDEF int stbi_info(char const *filename, int *x, int *y, int *comp) +{ + FILE *f = stbi__fopen(filename, "rb"); + int result; + if (!f) return stbi__err("can't fopen", "Unable to open file"); + result = stbi_info_from_file(f, x, y, comp); + fclose(f); + return result; +} + +STBIDEF int stbi_info_from_file(FILE *f, int *x, int *y, int *comp) +{ + int r; + stbi__context s; + long pos = ftell(f); + stbi__start_file(&s, f); + r = stbi__info_main(&s,x,y,comp); + fseek(f,pos,SEEK_SET); + return r; +} + +STBIDEF int stbi_is_16_bit(char const *filename) +{ + FILE *f = stbi__fopen(filename, "rb"); + int result; + if (!f) return stbi__err("can't fopen", "Unable to open file"); + result = stbi_is_16_bit_from_file(f); + fclose(f); + return result; +} + +STBIDEF int stbi_is_16_bit_from_file(FILE *f) +{ + int r; + stbi__context s; + long pos = ftell(f); + stbi__start_file(&s, f); + r = stbi__is_16_main(&s); + fseek(f,pos,SEEK_SET); + return r; +} +#endif // !STBI_NO_STDIO + +STBIDEF int stbi_info_from_memory(stbi_uc const *buffer, int len, int *x, int *y, int *comp) +{ + stbi__context s; + stbi__start_mem(&s,buffer,len); + return stbi__info_main(&s,x,y,comp); +} + +STBIDEF int stbi_info_from_callbacks(stbi_io_callbacks const *c, void *user, int *x, int *y, int *comp) +{ + stbi__context s; + stbi__start_callbacks(&s, (stbi_io_callbacks *) c, user); + return stbi__info_main(&s,x,y,comp); +} + +STBIDEF int stbi_is_16_bit_from_memory(stbi_uc const *buffer, int len) +{ + stbi__context s; + stbi__start_mem(&s,buffer,len); + return stbi__is_16_main(&s); +} + +STBIDEF int stbi_is_16_bit_from_callbacks(stbi_io_callbacks const *c, void *user) +{ + stbi__context s; + stbi__start_callbacks(&s, (stbi_io_callbacks *) c, user); + return stbi__is_16_main(&s); +} + +#endif // STB_IMAGE_IMPLEMENTATION + +/* + revision history: + 2.20 (2019-02-07) support utf8 filenames in Windows; fix warnings and platform ifdefs + 2.19 (2018-02-11) fix warning + 2.18 (2018-01-30) fix warnings + 2.17 (2018-01-29) change sbti__shiftsigned to avoid clang -O2 bug + 1-bit BMP + *_is_16_bit api + avoid warnings + 2.16 (2017-07-23) all functions have 16-bit variants; + STBI_NO_STDIO works again; + compilation fixes; + fix rounding in unpremultiply; + optimize vertical flip; + disable raw_len validation; + documentation fixes + 2.15 (2017-03-18) fix png-1,2,4 bug; now all Imagenet JPGs decode; + warning fixes; disable run-time SSE detection on gcc; + uniform handling of optional "return" values; + thread-safe initialization of zlib tables + 2.14 (2017-03-03) remove deprecated STBI_JPEG_OLD; fixes for Imagenet JPGs + 2.13 (2016-11-29) add 16-bit API, only supported for PNG right now + 2.12 (2016-04-02) fix typo in 2.11 PSD fix that caused crashes + 2.11 (2016-04-02) allocate large structures on the stack + remove white matting for transparent PSD + fix reported channel count for PNG & BMP + re-enable SSE2 in non-gcc 64-bit + support RGB-formatted JPEG + read 16-bit PNGs (only as 8-bit) + 2.10 (2016-01-22) avoid warning introduced in 2.09 by STBI_REALLOC_SIZED + 2.09 (2016-01-16) allow comments in PNM files + 16-bit-per-pixel TGA (not bit-per-component) + info() for TGA could break due to .hdr handling + info() for BMP to shares code instead of sloppy parse + can use STBI_REALLOC_SIZED if allocator doesn't support realloc + code cleanup + 2.08 (2015-09-13) fix to 2.07 cleanup, reading RGB PSD as RGBA + 2.07 (2015-09-13) fix compiler warnings + partial animated GIF support + limited 16-bpc PSD support + #ifdef unused functions + bug with < 92 byte PIC,PNM,HDR,TGA + 2.06 (2015-04-19) fix bug where PSD returns wrong '*comp' value + 2.05 (2015-04-19) fix bug in progressive JPEG handling, fix warning + 2.04 (2015-04-15) try to re-enable SIMD on MinGW 64-bit + 2.03 (2015-04-12) extra corruption checking (mmozeiko) + stbi_set_flip_vertically_on_load (nguillemot) + fix NEON support; fix mingw support + 2.02 (2015-01-19) fix incorrect assert, fix warning + 2.01 (2015-01-17) fix various warnings; suppress SIMD on gcc 32-bit without -msse2 + 2.00b (2014-12-25) fix STBI_MALLOC in progressive JPEG + 2.00 (2014-12-25) optimize JPG, including x86 SSE2 & NEON SIMD (ryg) + progressive JPEG (stb) + PGM/PPM support (Ken Miller) + STBI_MALLOC,STBI_REALLOC,STBI_FREE + GIF bugfix -- seemingly never worked + STBI_NO_*, STBI_ONLY_* + 1.48 (2014-12-14) fix incorrectly-named assert() + 1.47 (2014-12-14) 1/2/4-bit PNG support, both direct and paletted (Omar Cornut & stb) + optimize PNG (ryg) + fix bug in interlaced PNG with user-specified channel count (stb) + 1.46 (2014-08-26) + fix broken tRNS chunk (colorkey-style transparency) in non-paletted PNG + 1.45 (2014-08-16) + fix MSVC-ARM internal compiler error by wrapping malloc + 1.44 (2014-08-07) + various warning fixes from Ronny Chevalier + 1.43 (2014-07-15) + fix MSVC-only compiler problem in code changed in 1.42 + 1.42 (2014-07-09) + don't define _CRT_SECURE_NO_WARNINGS (affects user code) + fixes to stbi__cleanup_jpeg path + added STBI_ASSERT to avoid requiring assert.h + 1.41 (2014-06-25) + fix search&replace from 1.36 that messed up comments/error messages + 1.40 (2014-06-22) + fix gcc struct-initialization warning + 1.39 (2014-06-15) + fix to TGA optimization when req_comp != number of components in TGA; + fix to GIF loading because BMP wasn't rewinding (whoops, no GIFs in my test suite) + add support for BMP version 5 (more ignored fields) + 1.38 (2014-06-06) + suppress MSVC warnings on integer casts truncating values + fix accidental rename of 'skip' field of I/O + 1.37 (2014-06-04) + remove duplicate typedef + 1.36 (2014-06-03) + convert to header file single-file library + if de-iphone isn't set, load iphone images color-swapped instead of returning NULL + 1.35 (2014-05-27) + various warnings + fix broken STBI_SIMD path + fix bug where stbi_load_from_file no longer left file pointer in correct place + fix broken non-easy path for 32-bit BMP (possibly never used) + TGA optimization by Arseny Kapoulkine + 1.34 (unknown) + use STBI_NOTUSED in stbi__resample_row_generic(), fix one more leak in tga failure case + 1.33 (2011-07-14) + make stbi_is_hdr work in STBI_NO_HDR (as specified), minor compiler-friendly improvements + 1.32 (2011-07-13) + support for "info" function for all supported filetypes (SpartanJ) + 1.31 (2011-06-20) + a few more leak fixes, bug in PNG handling (SpartanJ) + 1.30 (2011-06-11) + added ability to load files via callbacks to accomidate custom input streams (Ben Wenger) + removed deprecated format-specific test/load functions + removed support for installable file formats (stbi_loader) -- would have been broken for IO callbacks anyway + error cases in bmp and tga give messages and don't leak (Raymond Barbiero, grisha) + fix inefficiency in decoding 32-bit BMP (David Woo) + 1.29 (2010-08-16) + various warning fixes from Aurelien Pocheville + 1.28 (2010-08-01) + fix bug in GIF palette transparency (SpartanJ) + 1.27 (2010-08-01) + cast-to-stbi_uc to fix warnings + 1.26 (2010-07-24) + fix bug in file buffering for PNG reported by SpartanJ + 1.25 (2010-07-17) + refix trans_data warning (Won Chun) + 1.24 (2010-07-12) + perf improvements reading from files on platforms with lock-heavy fgetc() + minor perf improvements for jpeg + deprecated type-specific functions so we'll get feedback if they're needed + attempt to fix trans_data warning (Won Chun) + 1.23 fixed bug in iPhone support + 1.22 (2010-07-10) + removed image *writing* support + stbi_info support from Jetro Lauha + GIF support from Jean-Marc Lienher + iPhone PNG-extensions from James Brown + warning-fixes from Nicolas Schulz and Janez Zemva (i.stbi__err. Janez (U+017D)emva) + 1.21 fix use of 'stbi_uc' in header (reported by jon blow) + 1.20 added support for Softimage PIC, by Tom Seddon + 1.19 bug in interlaced PNG corruption check (found by ryg) + 1.18 (2008-08-02) + fix a threading bug (local mutable static) + 1.17 support interlaced PNG + 1.16 major bugfix - stbi__convert_format converted one too many pixels + 1.15 initialize some fields for thread safety + 1.14 fix threadsafe conversion bug + header-file-only version (#define STBI_HEADER_FILE_ONLY before including) + 1.13 threadsafe + 1.12 const qualifiers in the API + 1.11 Support installable IDCT, colorspace conversion routines + 1.10 Fixes for 64-bit (don't use "unsigned long") + optimized upsampling by Fabian "ryg" Giesen + 1.09 Fix format-conversion for PSD code (bad global variables!) + 1.08 Thatcher Ulrich's PSD code integrated by Nicolas Schulz + 1.07 attempt to fix C++ warning/errors again + 1.06 attempt to fix C++ warning/errors again + 1.05 fix TGA loading to return correct *comp and use good luminance calc + 1.04 default float alpha is 1, not 255; use 'void *' for stbi_image_free + 1.03 bugfixes to STBI_NO_STDIO, STBI_NO_HDR + 1.02 support for (subset of) HDR files, float interface for preferred access to them + 1.01 fix bug: possible bug in handling right-side up bmps... not sure + fix bug: the stbi__bmp_load() and stbi__tga_load() functions didn't work at all + 1.00 interface to zlib that skips zlib header + 0.99 correct handling of alpha in palette + 0.98 TGA loader by lonesock; dynamically add loaders (untested) + 0.97 jpeg errors on too large a file; also catch another malloc failure + 0.96 fix detection of invalid v value - particleman@mollyrocket forum + 0.95 during header scan, seek to markers in case of padding + 0.94 STBI_NO_STDIO to disable stdio usage; rename all #defines the same + 0.93 handle jpegtran output; verbose errors + 0.92 read 4,8,16,24,32-bit BMP files of several formats + 0.91 output 24-bit Windows 3.0 BMP files + 0.90 fix a few more warnings; bump version number to approach 1.0 + 0.61 bugfixes due to Marc LeBlanc, Christopher Lloyd + 0.60 fix compiling as c++ + 0.59 fix warnings: merge Dave Moore's -Wall fixes + 0.58 fix bug: zlib uncompressed mode len/nlen was wrong endian + 0.57 fix bug: jpg last huffman symbol before marker was >9 bits but less than 16 available + 0.56 fix bug: zlib uncompressed mode len vs. nlen + 0.55 fix bug: restart_interval not initialized to 0 + 0.54 allow NULL for 'int *comp' + 0.53 fix bug in png 3->4; speedup png decoding + 0.52 png handles req_comp=3,4 directly; minor cleanup; jpeg comments + 0.51 obey req_comp requests, 1-component jpegs return as 1-component, + on 'test' only check type, not whether we support this variant + 0.50 (2006-11-19) + first released version +*/ + + +/* +------------------------------------------------------------------------------ +This software is available under 2 licenses -- choose whichever you prefer. +------------------------------------------------------------------------------ +ALTERNATIVE A - MIT License +Copyright (c) 2017 Sean Barrett +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +------------------------------------------------------------------------------ +ALTERNATIVE B - Public Domain (www.unlicense.org) +This is free and unencumbered software released into the public domain. +Anyone is free to copy, modify, publish, use, compile, sell, or distribute this +software, either in source code form or as a compiled binary, for any purpose, +commercial or non-commercial, and by any means. +In jurisdictions that recognize copyright laws, the author or authors of this +software dedicate any and all copyright interest in the software to the public +domain. We make this dedication for the benefit of the public at large and to +the detriment of our heirs and successors. We intend this dedication to be an +overt act of relinquishment in perpetuity of all present and future rights to +this software under copyright law. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------------ +*/ diff --git a/docs/face-knn-source/kernels/knn_float4_topk.cl b/docs/face-knn-source/kernels/knn_float4_topk.cl new file mode 100644 index 0000000..887eb9a --- /dev/null +++ b/docs/face-knn-source/kernels/knn_float4_topk.cl @@ -0,0 +1,192 @@ + +#ifndef DIM +#define DIM 128 +#endif + +#ifndef TOPK +#define TOPK 32 +#endif + +#ifndef QUERY_COUNT +#define QUERY_COUNT 1 +#endif + +/* + * Numero di work-item che collaborano per ogni query. + */ +#ifndef LANES_PER_QUERY +#define LANES_PER_QUERY 8 +#endif + +/* + * Ogni work-group contiene 8 work-item. + * + * Ogni work-item calcola un TOPK locale. + * Poi il work-item 0 fonde gli 8 TOPK locali. + */ + +__kernel void knn( + __global const float *db, + __global const float *queries, + __global uint *indices, + __global float *values, + uint n) +{ + uint lane = get_local_id(0); + uint q = get_group_id(0); + + if (q >= QUERY_COUNT) + return; + + /* + * TOPK locale per ogni work-item. + */ + float best_score[TOPK]; + uint best_index[TOPK]; + + for (uint k = 0; k < TOPK; ++k) { + best_score[k] = -3.402823466e+38f; + best_index[k] = 0xffffffff; + } + + __global const float *query = + &queries[q * DIM]; + + /* + * Ogni lane analizza una parte del database. + * + * Esempio con 8 lane: + * + * lane 0 -> 0, 8, 16, 24, ... + * lane 1 -> 1, 9, 17, 25, ... + * ... + * lane 7 -> 7, 15, 23, 31, ... + */ + for (uint i = lane; i < n; i += LANES_PER_QUERY) { + + __global const float *v = + &db[i * DIM]; + + float s = 0.0f; + + for (uint j = 0; j < DIM; ++j) + s += v[j] * query[j]; + + if (s <= best_score[TOPK - 1]) + continue; + + int pos = TOPK - 1; + + while (pos > 0 && + s > best_score[pos - 1]) { + --pos; + } + + for (int k = TOPK - 1; k > pos; --k) { + best_score[k] = + best_score[k - 1]; + + best_index[k] = + best_index[k - 1]; + } + + best_score[pos] = s; + best_index[pos] = i; + } + + /* + * TOPK locali nella local memory. + * + * 8 lane × 32 elementi. + */ + __local float local_scores[LANES_PER_QUERY][TOPK]; + __local uint local_indices[LANES_PER_QUERY][TOPK]; + + for (uint k = 0; k < TOPK; ++k) { + local_scores[lane][k] = best_score[k]; + local_indices[lane][k] = best_index[k]; + } + + /* + * Tutti i lane devono aver scritto + * la local memory prima della riduzione. + */ + barrier(CLK_LOCAL_MEM_FENCE); + + /* + * Solo lane 0 esegue il merge finale. + */ + if (lane == 0) { + + float final_score[TOPK]; + uint final_index[TOPK]; + + for (uint k = 0; k < TOPK; ++k) { + final_score[k] = + -3.402823466e+38f; + + final_index[k] = + 0xffffffff; + } + + /* + * Fonde gli 8 TOPK locali. + * + * Al massimo 8 × 32 = 256 candidati. + */ + for (uint l = 0; + l < LANES_PER_QUERY; + ++l) { + + for (uint k = 0; + k < TOPK; + ++k) { + + float s = + local_scores[l][k]; + + uint idx = + local_indices[l][k]; + + if (idx == 0xffffffff) + continue; + + if (s <= final_score[TOPK - 1]) + continue; + + int pos = TOPK - 1; + + while (pos > 0 && + s > final_score[pos - 1]) { + --pos; + } + + for (int p = TOPK - 1; + p > pos; + --p) { + + final_score[p] = + final_score[p - 1]; + + final_index[p] = + final_index[p - 1]; + } + + final_score[pos] = s; + final_index[pos] = idx; + } + } + + /* + * Risultato finale della query. + */ + for (uint k = 0; k < TOPK; ++k) { + indices[q * TOPK + k] = + final_index[k]; + + values[q * TOPK + k] = + final_score[k]; + } + } +} + diff --git a/docs/face-knn-source/kernels/knn_float4_topk.cl.sha256 b/docs/face-knn-source/kernels/knn_float4_topk.cl.sha256 new file mode 100644 index 0000000..39256ab --- /dev/null +++ b/docs/face-knn-source/kernels/knn_float4_topk.cl.sha256 @@ -0,0 +1 @@ +f1979b6aa6d24633c6ac261fa66204e7f714a398091c87552712c43f8bb29467 knn_float4_topk.cl diff --git a/docs/face-knn-source/kernels/knn_reference.cl b/docs/face-knn-source/kernels/knn_reference.cl new file mode 100644 index 0000000..9644ab6 --- /dev/null +++ b/docs/face-knn-source/kernels/knn_reference.cl @@ -0,0 +1,181 @@ + +#define DIM 128 +#define TOPK 32 + +/* + * Numero di work-item che collaborano per ogni query. + */ +#define LANES_PER_QUERY 8 + +/* + * Ogni work-group contiene 8 work-item. + * + * Ogni work-item calcola un TOPK locale. + * Poi il work-item 0 fonde gli 8 TOPK locali. + */ + +__kernel void knn( + __global const float *db, + __global const float *queries, + __global uint *indices, + __global float *values, + uint n) +{ + uint lane = get_local_id(0); + uint q = get_group_id(0); + + if (q >= 256) + return; + + /* + * TOPK locale per ogni work-item. + */ + float best_score[TOPK]; + uint best_index[TOPK]; + + for (uint k = 0; k < TOPK; ++k) { + best_score[k] = -3.402823466e+38f; + best_index[k] = 0xffffffff; + } + + __global const float *query = + &queries[q * DIM]; + + /* + * Ogni lane analizza una parte del database. + * + * Esempio con 8 lane: + * + * lane 0 -> 0, 8, 16, 24, ... + * lane 1 -> 1, 9, 17, 25, ... + * ... + * lane 7 -> 7, 15, 23, 31, ... + */ + for (uint i = lane; i < n; i += LANES_PER_QUERY) { + + __global const float *v = + &db[i * DIM]; + + float s = 0.0f; + + for (uint j = 0; j < DIM; ++j) + s += v[j] * query[j]; + + if (s <= best_score[TOPK - 1]) + continue; + + int pos = TOPK - 1; + + while (pos > 0 && + s > best_score[pos - 1]) { + --pos; + } + + for (int k = TOPK - 1; k > pos; --k) { + best_score[k] = + best_score[k - 1]; + + best_index[k] = + best_index[k - 1]; + } + + best_score[pos] = s; + best_index[pos] = i; + } + + /* + * TOPK locali nella local memory. + * + * 8 lane × 32 elementi. + */ + __local float local_scores[LANES_PER_QUERY][TOPK]; + __local uint local_indices[LANES_PER_QUERY][TOPK]; + + for (uint k = 0; k < TOPK; ++k) { + local_scores[lane][k] = best_score[k]; + local_indices[lane][k] = best_index[k]; + } + + /* + * Tutti i lane devono aver scritto + * la local memory prima della riduzione. + */ + barrier(CLK_LOCAL_MEM_FENCE); + + /* + * Solo lane 0 esegue il merge finale. + */ + if (lane == 0) { + + float final_score[TOPK]; + uint final_index[TOPK]; + + for (uint k = 0; k < TOPK; ++k) { + final_score[k] = + -3.402823466e+38f; + + final_index[k] = + 0xffffffff; + } + + /* + * Fonde gli 8 TOPK locali. + * + * Al massimo 8 × 32 = 256 candidati. + */ + for (uint l = 0; + l < LANES_PER_QUERY; + ++l) { + + for (uint k = 0; + k < TOPK; + ++k) { + + float s = + local_scores[l][k]; + + uint idx = + local_indices[l][k]; + + if (idx == 0xffffffff) + continue; + + if (s <= final_score[TOPK - 1]) + continue; + + int pos = TOPK - 1; + + while (pos > 0 && + s > final_score[pos - 1]) { + --pos; + } + + for (int p = TOPK - 1; + p > pos; + --p) { + + final_score[p] = + final_score[p - 1]; + + final_index[p] = + final_index[p - 1]; + } + + final_score[pos] = s; + final_index[pos] = idx; + } + } + + /* + * Risultato finale della query. + */ + for (uint k = 0; k < TOPK; ++k) { + indices[q * TOPK + k] = + final_index[k]; + + values[q * TOPK + k] = + final_score[k]; + } + } +} + diff --git a/docs/face-knn-source/src/face_engine.cc b/docs/face-knn-source/src/face_engine.cc new file mode 100644 index 0000000..46e43c0 --- /dev/null +++ b/docs/face-knn-source/src/face_engine.cc @@ -0,0 +1,856 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define STB_IMAGE_IMPLEMENTATION +#include "stb_image.h" + +#include "rknn_api.h" +#include "face_engine.h" + +static constexpr int SCRFD_SIZE = 640; +static constexpr int SFACE_SIZE = 112; +static constexpr int EMBED_DIM = 128; + +static uint16_t float_to_fp16(float value) +{ + uint32_t bits; + std::memcpy(&bits, &value, sizeof(bits)); + + uint32_t sign = (bits >> 31) & 1; + int exp = ((bits >> 23) & 0xff) - 127; + uint32_t mant = bits & 0x7fffff; + + if (exp == 128) { + return (sign << 15) | 0x7c00; + } + + if (exp > 15) { + return (sign << 15) | 0x7c00; + } + + if (exp < -14) { + if (exp < -24) + return sign << 15; + + mant |= 0x800000; + int shift = -exp - 14; + uint16_t m = mant >> (shift + 13); + + return (sign << 15) | m; + } + + uint16_t h_exp = (uint16_t)(exp + 15); + uint16_t h_mant = (uint16_t)(mant >> 13); + + return (sign << 15) | (h_exp << 10) | h_mant; +} + +static float fp16_to_float(uint16_t h) +{ + uint32_t sign = (h >> 15) & 1; + uint32_t exp = (h >> 10) & 0x1f; + uint32_t mant = h & 0x3ff; + + uint32_t bits; + + if (exp == 0) { + if (mant == 0) { + bits = sign << 31; + } else { + float v = mant / 1024.0f; + v = std::ldexp(v, -14); + return sign ? -v : v; + } + } else if (exp == 31) { + bits = (sign << 31) | 0x7f800000 | (mant << 13); + } else { + uint32_t fexp = exp - 15 + 127; + bits = (sign << 31) | (fexp << 23) | (mant << 13); + } + + float v; + std::memcpy(&v, &bits, sizeof(v)); + return v; +} + +static float iou(const Face& a, const Face& b) +{ + float xx1 = std::max(a.x1, b.x1); + float yy1 = std::max(a.y1, b.y1); + float xx2 = std::min(a.x2, b.x2); + float yy2 = std::min(a.y2, b.y2); + + float w = std::max(0.0f, xx2 - xx1); + float h = std::max(0.0f, yy2 - yy1); + float inter = w * h; + + float area_a = std::max(0.0f, a.x2-a.x1) * + std::max(0.0f, a.y2-a.y1); + + float area_b = std::max(0.0f, b.x2-b.x1) * + std::max(0.0f, b.y2-b.y1); + + return inter / (area_a + area_b - inter + 1e-6f); +} + +static void nms(std::vector& faces, float threshold) +{ + std::sort(faces.begin(), faces.end(), + [](const Face& a, const Face& b) { + return a.score > b.score; + }); + + std::vector result; + + for (const auto& f : faces) { + bool keep = true; + + for (const auto& r : result) { + if (iou(f, r) > threshold) { + keep = false; + break; + } + } + + if (keep) + result.push_back(f); + } + + faces.swap(result); +} + +struct Image { + int w = 0; + int h = 0; + std::vector rgb; +}; + +static bool normalize_rotation( + int rotation_degrees, + int& normalized_rotation) +{ + int value = rotation_degrees % 360; + + if (value < 0) + value += 360; + + if ( + value != 0 + && value != 90 + && value != 180 + && value != 270 + ) { + return false; + } + + normalized_rotation = value; + return true; +} + +static Image rotate_image( + const Image& source, + int rotation_degrees) +{ + if (rotation_degrees == 0) + return source; + + Image destination; + + if ( + rotation_degrees == 90 + || rotation_degrees == 270 + ) { + destination.w = source.h; + destination.h = source.w; + } else { + destination.w = source.w; + destination.h = source.h; + } + + destination.rgb.resize( + static_cast(destination.w) + * static_cast(destination.h) + * 3); + + for (int source_y = 0; source_y < source.h; ++source_y) { + for (int source_x = 0; source_x < source.w; ++source_x) { + int destination_x = 0; + int destination_y = 0; + + switch (rotation_degrees) { + case 90: + destination_x = source.h - 1 - source_y; + destination_y = source_x; + break; + + case 180: + destination_x = source.w - 1 - source_x; + destination_y = source.h - 1 - source_y; + break; + + case 270: + destination_x = source_y; + destination_y = source.w - 1 - source_x; + break; + + default: + destination_x = source_x; + destination_y = source_y; + break; + } + + const std::size_t source_offset = + ( + static_cast(source_y) + * static_cast(source.w) + + static_cast(source_x) + ) * 3; + + const std::size_t destination_offset = + ( + static_cast(destination_y) + * static_cast(destination.w) + + static_cast(destination_x) + ) * 3; + + destination.rgb[destination_offset + 0] = + source.rgb[source_offset + 0]; + + destination.rgb[destination_offset + 1] = + source.rgb[source_offset + 1]; + + destination.rgb[destination_offset + 2] = + source.rgb[source_offset + 2]; + } + } + + return destination; +} + +static bool load_image(const std::string& path, Image& img) +{ + int c = 0; + + unsigned char* p = + stbi_load(path.c_str(), &img.w, &img.h, &c, 3); + + if (!p) { + std::cerr << "Errore caricamento: " << path << "\n"; + return false; + } + + img.rgb.assign(p, p + img.w * img.h * 3); + stbi_image_free(p); + + return true; +} + +static void resize_bilinear_rgb( + const Image& src, + std::vector& dst, + int dw, + int dh) +{ + dst.resize(dw * dh * 3); + + float sx = (float)src.w / dw; + float sy = (float)src.h / dh; + + for (int y = 0; y < dh; ++y) { + float fy = (y + 0.5f) * sy - 0.5f; + int y0 = (int)std::floor(fy); + float wy = fy - y0; + + if (y0 < 0) { + y0 = 0; + wy = 0; + } + + int y1 = std::min(y0 + 1, src.h - 1); + + for (int x = 0; x < dw; ++x) { + float fx = (x + 0.5f) * sx - 0.5f; + int x0 = (int)std::floor(fx); + float wx = fx - x0; + + if (x0 < 0) { + x0 = 0; + wx = 0; + } + + int x1 = std::min(x0 + 1, src.w - 1); + + for (int c = 0; c < 3; ++c) { + float p00 = src.rgb[(y0*src.w+x0)*3+c]; + float p01 = src.rgb[(y0*src.w+x1)*3+c]; + float p10 = src.rgb[(y1*src.w+x0)*3+c]; + float p11 = src.rgb[(y1*src.w+x1)*3+c]; + + float v = + p00 * (1-wx) * (1-wy) + + p01 * wx * (1-wy) + + p10 * (1-wx) * wy + + p11 * wx * wy; + + dst[(y*dw+x)*3+c] = + (uint8_t)std::clamp((int)std::round(v), 0, 255); + } + } + } +} + +class Scrfd { +public: + rknn_context ctx = 0; + + bool init(const char* model) + { + FILE* fp = fopen(model, "rb"); + if (!fp) { + perror(model); + return false; + } + + fseek(fp, 0, SEEK_END); + size_t size = ftell(fp); + fseek(fp, 0, SEEK_SET); + + std::vector data(size); + fread(data.data(), 1, size, fp); + fclose(fp); + + int ret = rknn_init(&ctx, data.data(), size, 0, nullptr); + if (ret != 0) { + std::cerr << "rknn_init SCRFD failed: " << ret << "\n"; + return false; + } + + return true; + } + + std::vector detect(const Image& image) + { + float scale = + std::min(640.0f / image.w, 640.0f / image.h); + + int rw = (int)std::round(image.w * scale); + int rh = (int)std::round(image.h * scale); + + std::vector resized; + resize_bilinear_rgb(image, resized, rw, rh); + + std::vector input( + SCRFD_SIZE * SCRFD_SIZE * 3); + + for (int y = 0; y < rh; ++y) { + for (int x = 0; x < rw; ++x) { + int dst = (y * SCRFD_SIZE + x) * 3; + int src = (y * rw + x) * 3; + + for (int c = 0; c < 3; ++c) { + float v = + ((float)resized[src+c] - 127.5f) / 128.0f; + + input[dst+c] = float_to_fp16(v); + } + } + } + + rknn_input in{}; + in.index = 0; + in.type = RKNN_TENSOR_FLOAT16; + in.fmt = RKNN_TENSOR_NHWC; + in.size = input.size() * sizeof(uint16_t); + in.buf = input.data(); + in.pass_through = 1; + + int ret = rknn_inputs_set(ctx, 1, &in); + if (ret != 0) { + std::cerr << "SCRFD inputs_set failed\n"; + return {}; + } + + ret = rknn_run(ctx, nullptr); + if (ret != 0) { + std::cerr << "SCRFD run failed\n"; + return {}; + } + + rknn_output outputs[9]{}; + + for (int i = 0; i < 9; ++i) { + outputs[i].want_float = 0; + } + + ret = rknn_outputs_get(ctx, 9, outputs, nullptr); + if (ret != 0) { + std::cerr << "SCRFD outputs_get failed\n"; + return {}; + } + + const int strides[3] = {8, 16, 32}; + const int counts[3] = {12800, 3200, 800}; + + std::vector faces; + + const float score_threshold = 0.50f; + + for (int level = 0; level < 3; ++level) { + const int stride = strides[level]; + const int count = counts[level]; + + const uint16_t* scores = + (const uint16_t*)outputs[level].buf; + + const uint16_t* bbox = + (const uint16_t*)outputs[3 + level].buf; + + const uint16_t* kps = + (const uint16_t*)outputs[6 + level].buf; + + int feat_w = SCRFD_SIZE / stride; + + for (int i = 0; i < count; ++i) { + float score = fp16_to_float(scores[i]); + + if (score < score_threshold) + continue; + + int anchor = i % 2; + int p = i / 2; + + int gx = p % feat_w; + int gy = p / feat_w; + + float cx = gx * stride; + float cy = gy * stride; + + /* + * AnchorGenerator con due anchor: + * centers duplicati sullo stesso punto. + */ + + (void)anchor; + + float l = fp16_to_float(bbox[i*4+0]) * stride; + float t = fp16_to_float(bbox[i*4+1]) * stride; + float r = fp16_to_float(bbox[i*4+2]) * stride; + float b = fp16_to_float(bbox[i*4+3]) * stride; + + Face f; + f.score = score; + + f.x1 = (cx - l) / scale; + f.y1 = (cy - t) / scale; + f.x2 = (cx + r) / scale; + f.y2 = (cy + b) / scale; + + for (int j = 0; j < 5; ++j) { + float x = + cx + fp16_to_float(kps[i*10+j*2]) + * stride; + + float y = + cy + fp16_to_float(kps[i*10+j*2+1]) + * stride; + + f.kps[j].x = x / scale; + f.kps[j].y = y / scale; + } + + f.x1 = std::clamp(f.x1, 0.0f, (float)image.w); + f.y1 = std::clamp(f.y1, 0.0f, (float)image.h); + f.x2 = std::clamp(f.x2, 0.0f, (float)image.w); + f.y2 = std::clamp(f.y2, 0.0f, (float)image.h); + + faces.push_back(f); + } + } + + rknn_outputs_release(ctx, 9, outputs); + + nms(faces, 0.45f); + + return faces; + } + + ~Scrfd() + { + if (ctx) + rknn_destroy(ctx); + } +}; + +static const Point sface_template[5] = { + {38.2946f, 51.6963f}, + {73.5318f, 51.5014f}, + {56.0252f, 71.7366f}, + {41.5493f, 92.3655f}, + {70.7299f, 92.2041f} +}; + +/* + * Trova la trasformazione similarity: + * + * x' = a*x - b*y + tx + * y' = b*x + a*y + ty + */ +static void similarity_transform( + const Point src[5], + float& a, + float& b, + float& tx, + float& ty) +{ + double sx = 0, sy = 0; + double dx = 0, dy = 0; + + for (int i = 0; i < 5; ++i) { + sx += src[i].x; + sy += src[i].y; + dx += sface_template[i].x; + dy += sface_template[i].y; + } + + sx /= 5; + sy /= 5; + dx /= 5; + dy /= 5; + + double num_a = 0; + double num_b = 0; + double den = 0; + + for (int i = 0; i < 5; ++i) { + double x = src[i].x - sx; + double y = src[i].y - sy; + + double X = sface_template[i].x - dx; + double Y = sface_template[i].y - dy; + + num_a += x*X + y*Y; + num_b += x*Y - y*X; + den += x*x + y*y; + } + + a = (float)(num_a / den); + b = (float)(num_b / den); + + tx = (float)(dx - a*sx + b*sy); + ty = (float)(dy - b*sx - a*sy); +} + +static std::vector align_face( + const Image& image, + const Face& face) +{ + float a, b, tx, ty; + + similarity_transform(face.kps, a, b, tx, ty); + + /* + * Inversa della similarity transform. + * + * src = A^-1 * (dst - t) + */ + float denom = a*a + b*b; + + std::vector output( + SFACE_SIZE * SFACE_SIZE * 3); + + for (int y = 0; y < SFACE_SIZE; ++y) { + for (int x = 0; x < SFACE_SIZE; ++x) { + + float X = x - tx; + float Y = y - ty; + + float sx = + (a*X + b*Y) / denom; + + float sy = + (-b*X + a*Y) / denom; + + int x0 = (int)std::floor(sx); + int y0 = (int)std::floor(sy); + + float wx = sx - x0; + float wy = sy - y0; + + uint8_t rgb[3] = {0,0,0}; + + if (x0 >= 0 && + y0 >= 0 && + x0 + 1 < image.w && + y0 + 1 < image.h) { + + int x1 = x0 + 1; + int y1 = y0 + 1; + + for (int c = 0; c < 3; ++c) { + float p00 = + image.rgb[(y0*image.w+x0)*3+c]; + + float p01 = + image.rgb[(y0*image.w+x1)*3+c]; + + float p10 = + image.rgb[(y1*image.w+x0)*3+c]; + + float p11 = + image.rgb[(y1*image.w+x1)*3+c]; + + float v = + p00*(1-wx)*(1-wy) + + p01*wx*(1-wy) + + p10*(1-wx)*wy + + p11*wx*wy; + + rgb[c] = + (uint8_t)std::clamp( + (int)std::round(v), 0, 255); + } + } + + int idx = (y*SFACE_SIZE+x)*3; + + /* + * SFace OpenCV: + * blobFromImage(..., swapRB=true, scalefactor=1) + * + * Noi partiamo già da RGB, quindi non facciamo + * nessun ulteriore swap e nessuna normalizzazione. + */ + output[idx+0] = float_to_fp16(rgb[0]); + output[idx+1] = float_to_fp16(rgb[1]); + output[idx+2] = float_to_fp16(rgb[2]); + } + } + + return output; +} + +class SFace { +public: + rknn_context ctx = 0; + + bool init(const char* model) + { + FILE* fp = fopen(model, "rb"); + if (!fp) { + perror(model); + return false; + } + + fseek(fp, 0, SEEK_END); + size_t size = ftell(fp); + fseek(fp, 0, SEEK_SET); + + std::vector data(size); + fread(data.data(), 1, size, fp); + fclose(fp); + + int ret = rknn_init(&ctx, data.data(), size, 0, nullptr); + + if (ret != 0) { + std::cerr << "rknn_init SFace failed: " + << ret << "\n"; + return false; + } + + return true; + } + + std::vector feature( + const std::vector& input) + { + rknn_input in{}; + + in.index = 0; + in.type = RKNN_TENSOR_FLOAT16; + in.fmt = RKNN_TENSOR_NHWC; + in.size = input.size() * sizeof(uint16_t); + in.buf = (void*)input.data(); + in.pass_through = 1; + + int ret = rknn_inputs_set(ctx, 1, &in); + + if (ret != 0) { + std::cerr << "SFace inputs_set failed\n"; + return {}; + } + + ret = rknn_run(ctx, nullptr); + + if (ret != 0) { + std::cerr << "SFace run failed\n"; + return {}; + } + + rknn_output output{}; + + output.want_float = 0; + + ret = rknn_outputs_get( + ctx, 1, &output, nullptr); + + if (ret != 0) { + std::cerr << "SFace outputs_get failed\n"; + return {}; + } + + const uint16_t* p = + (const uint16_t*)output.buf; + + std::vector emb(EMBED_DIM); + + for (int i = 0; i < EMBED_DIM; ++i) + emb[i] = fp16_to_float(p[i]); + + rknn_outputs_release(ctx, 1, &output); + + /* + * L2 normalization. + */ + double norm = 0; + + for (float v : emb) + norm += (double)v * v; + + norm = std::sqrt(norm); + + if (norm > 0) { + for (float& v : emb) + v /= (float)norm; + } + + return emb; + } + + ~SFace() + { + if (ctx) + rknn_destroy(ctx); + } +}; + +static std::vector process_image( + const Image& image, + Scrfd& detector, + SFace& recognizer) +{ + auto faces = detector.detect(image); + + for (auto& face : faces) { + auto aligned = align_face(image, face); + face.embedding = + recognizer.feature(aligned); + } + + return faces; +} + +struct FaceEngine::Impl { + Scrfd detector; + SFace recognizer; + bool initialized = false; +}; + +FaceEngine::FaceEngine() + : impl_(new Impl()) +{ +} + +FaceEngine::~FaceEngine() +{ + delete impl_; +} + +bool FaceEngine::init( + const std::string& scrfd_model_path, + const std::string& sface_model_path) +{ + if (impl_->initialized) + return true; + + if (!impl_->detector.init(scrfd_model_path.c_str())) + return false; + + if (!impl_->recognizer.init(sface_model_path.c_str())) + return false; + + impl_->initialized = true; + return true; +} + +bool FaceEngine::process_image( + const std::string& image_path, + std::vector& faces, + int& image_width, + int& image_height, + int rotation_degrees) +{ + faces.clear(); + image_width = 0; + image_height = 0; + + if (!impl_->initialized) { + std::cerr << "FaceEngine non inizializzato\n"; + return false; + } + + int normalized_rotation = 0; + + if (!normalize_rotation( + rotation_degrees, + normalized_rotation)) { + std::cerr + << "Rotazione non valida: " + << rotation_degrees + << ". Valori ammessi: 0, 90, 180, 270\n"; + + return false; + } + + Image source_image; + + if (!load_image(image_path, source_image)) + return false; + + Image oriented_image = rotate_image( + source_image, + normalized_rotation); + + image_width = oriented_image.w; + image_height = oriented_image.h; + + faces = ::process_image( + oriented_image, + impl_->detector, + impl_->recognizer); + + for (const Face& face : faces) { + if (face.embedding.size() != EMBED_DIM) { + std::cerr + << "Embedding non valido per " + << image_path + << ": attesi " + << EMBED_DIM + << " valori, trovati " + << face.embedding.size() + << "\n"; + + faces.clear(); + return false; + } + } + + return true; +} + diff --git a/docs/face-knn-source/src/face_knn.cc b/docs/face-knn-source/src/face_knn.cc new file mode 100644 index 0000000..1b58ed8 --- /dev/null +++ b/docs/face-knn-source/src/face_knn.cc @@ -0,0 +1,781 @@ +#include "knn_opencl.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + + +class ScopedStdoutToStderr { +public: + ScopedStdoutToStderr() + { + std::cout.flush(); + std::fflush(stdout); + + saved_stdout_ = + ::dup(STDOUT_FILENO); + + if (saved_stdout_ < 0) { + throw std::runtime_error( + "Impossibile salvare stdout" + ); + } + + if ( + ::dup2( + STDERR_FILENO, + STDOUT_FILENO + ) < 0 + ) { + ::close(saved_stdout_); + saved_stdout_ = -1; + + throw std::runtime_error( + "Impossibile deviare stdout" + ); + } + } + + ~ScopedStdoutToStderr() + { + if (saved_stdout_ < 0) { + return; + } + + std::cout.flush(); + std::fflush(stdout); + + ::dup2( + saved_stdout_, + STDOUT_FILENO + ); + + ::close(saved_stdout_); + } + + ScopedStdoutToStderr( + const ScopedStdoutToStderr& + ) = delete; + + ScopedStdoutToStderr& operator=( + const ScopedStdoutToStderr& + ) = delete; + +private: + int saved_stdout_ = -1; +}; + + +struct Arguments { + std::string queries_path; + + std::string database_path; + + std::string self_map_path; + + std::string backend = "cpu"; + + std::size_t query_count = 0; + + std::size_t database_count = 0; + + std::uint32_t dimension = 128; + + std::uint32_t top_k = 1; + + float threshold = 0.50f; +}; + + +void print_usage( + const char* program) +{ + std::cerr + << "Uso:\n" + << " " + << program + << " --queries FILE" + << " --database FILE" + << " --query-count N" + << " --database-count N" + << " --dimension N" + << " --top-k N" + << " --backend cpu" + << " [--threshold SCORE]" + << " [--self-map FILE]" + << "\n"; +} + + +std::string require_value( + int argc, + char** argv, + int& index) +{ + if (index + 1 >= argc) { + throw std::invalid_argument( + std::string( + "Valore mancante per " + ) + + argv[index] + ); + } + + index += 1; + + return argv[index]; +} + + +std::size_t parse_size( + const std::string& text, + const char* name) +{ + std::size_t consumed = 0; + + const auto value = + std::stoull( + text, + &consumed, + 10 + ); + + if ( + consumed != text.size() + || value == 0 + ) { + throw std::invalid_argument( + std::string(name) + + " non valido: " + + text + ); + } + + return static_cast( + value + ); +} + + +std::uint32_t parse_u32( + const std::string& text, + const char* name) +{ + const std::size_t value = + parse_size( + text, + name + ); + + if ( + value + > std::numeric_limits< + std::uint32_t + >::max() + ) { + throw std::invalid_argument( + std::string(name) + + " supera uint32" + ); + } + + return static_cast( + value + ); +} + + +float parse_float( + const std::string& text, + const char* name) +{ + std::size_t consumed = 0; + + const float value = + std::stof( + text, + &consumed + ); + + if (consumed != text.size()) { + throw std::invalid_argument( + std::string(name) + + " non valido: " + + text + ); + } + + return value; +} + + +Arguments parse_arguments( + int argc, + char** argv) +{ + Arguments arguments; + + for ( + int index = 1; + index < argc; + ++index + ) { + const std::string option = + argv[index]; + + if ( + option == "--help" + || option == "-h" + ) { + print_usage(argv[0]); + std::exit(0); + } + + if (option == "--queries") { + arguments.queries_path = + require_value( + argc, + argv, + index + ); + } else if ( + option == "--database" + ) { + arguments.database_path = + require_value( + argc, + argv, + index + ); + } else if ( + option == "--self-map" + ) { + arguments.self_map_path = + require_value( + argc, + argv, + index + ); + } else if ( + option == "--backend" + ) { + arguments.backend = + require_value( + argc, + argv, + index + ); + } else if ( + option == "--query-count" + ) { + arguments.query_count = + parse_size( + require_value( + argc, + argv, + index + ), + "query-count" + ); + } else if ( + option == "--database-count" + ) { + arguments.database_count = + parse_size( + require_value( + argc, + argv, + index + ), + "database-count" + ); + } else if ( + option == "--dimension" + ) { + arguments.dimension = + parse_u32( + require_value( + argc, + argv, + index + ), + "dimension" + ); + } else if ( + option == "--top-k" + ) { + arguments.top_k = + parse_u32( + require_value( + argc, + argv, + index + ), + "top-k" + ); + } else if ( + option == "--threshold" + ) { + arguments.threshold = + parse_float( + require_value( + argc, + argv, + index + ), + "threshold" + ); + } else { + throw std::invalid_argument( + "Opzione sconosciuta: " + + option + ); + } + } + + if ( + arguments.queries_path.empty() + || arguments.database_path.empty() + || arguments.query_count == 0 + || arguments.database_count == 0 + ) { + throw std::invalid_argument( + "Argomenti obbligatori mancanti" + ); + } + + if ( + arguments.backend != "cpu" + && arguments.backend != "opencl" + ) { + throw std::invalid_argument( + "Backend non supportato: " + + arguments.backend + ); + } + + return arguments; +} + + +std::vector read_float_file( + const std::string& path, + std::size_t expected_values) +{ + std::ifstream input( + path, + std::ios::binary + ); + + if (!input) { + throw std::runtime_error( + "Impossibile aprire: " + + path + ); + } + + input.seekg( + 0, + std::ios::end + ); + + const std::streamoff byte_count = + input.tellg(); + + input.seekg( + 0, + std::ios::beg + ); + + const auto expected_bytes = + static_cast( + expected_values + * sizeof(float) + ); + + if (byte_count != expected_bytes) { + throw std::runtime_error( + "Dimensione file incoerente: " + + path + + ", byte=" + + std::to_string(byte_count) + + ", attesi=" + + std::to_string( + expected_bytes + ) + ); + } + + std::vector values( + expected_values + ); + + input.read( + reinterpret_cast( + values.data() + ), + byte_count + ); + + if (!input) { + throw std::runtime_error( + "Lettura incompleta: " + + path + ); + } + + return values; +} + + +std::vector read_self_map( + const std::string& path, + std::size_t query_count) +{ + std::vector values( + query_count, + -1 + ); + + if (path.empty()) { + return values; + } + + std::ifstream input(path); + + if (!input) { + throw std::runtime_error( + "Impossibile aprire self-map: " + + path + ); + } + + std::size_t index = 0; + std::int64_t value = -1; + + while (input >> value) { + if (index >= query_count) { + throw std::runtime_error( + "Self-map contiene " + "troppe righe" + ); + } + + values[index] = value; + index += 1; + } + + if (index != query_count) { + throw std::runtime_error( + "Self-map contiene " + + std::to_string(index) + + " righe, attese " + + std::to_string( + query_count + ) + ); + } + + return values; +} + + +void write_json( + const face_knn::SearchOutput& output, + const std::vector& self_map, + float threshold) +{ + std::size_t recognized_count = 0; + + std::cout + << std::setprecision(9) + << "{\n" + << " \"backend\": \"" + << output.backend + << "\",\n" + << " \"device_name\": \"" + << output.device_name + << "\",\n" + << " \"query_count\": " + << output.query_count + << ",\n" + << " \"database_count\": " + << output.database_count + << ",\n" + << " \"dimension\": " + << output.dimension + << ",\n" + << " \"top_k\": " + << output.top_k + << ",\n" + << " \"threshold\": " + << threshold + << ",\n" + << " \"leave_one_out\": " + << ( + self_map.empty() + ? "false" + : "true" + ) + << ",\n" + << " \"elapsed_ms\": " + << output.elapsed_ms + << ",\n" + << " \"results\": [\n"; + + for ( + std::size_t query_index = 0; + query_index < output.query_count; + ++query_index + ) { + auto neighbors = + face_knn::query_neighbors( + output, + query_index + ); + + const std::int64_t excluded = + self_map[query_index]; + + if (excluded >= 0) { + neighbors = + face_knn::exclude_neighbor( + neighbors, + static_cast< + std::uint32_t + >(excluded) + ); + } + + if (neighbors.empty()) { + throw std::runtime_error( + "Nessun vicino disponibile " + "dopo leave-one-out" + ); + } + + const auto& best = + neighbors.front(); + + const bool recognized = + best.score >= threshold; + + if (recognized) { + recognized_count += 1; + } + + std::cout + << " {\n" + << " \"query_index\": " + << query_index + << ",\n" + << " \"excluded_index\": "; + + if (excluded < 0) { + std::cout << "null"; + } else { + std::cout << excluded; + } + + std::cout + << ",\n" + << " \"recognized\": " + << ( + recognized + ? "true" + : "false" + ) + << ",\n" + << " \"best_index\": " + << best.index + << ",\n" + << " \"best_score\": " + << best.score + << ",\n" + << " \"neighbors\": [\n"; + + for ( + std::size_t position = 0; + position < neighbors.size(); + ++position + ) { + const auto& neighbor = + neighbors[position]; + + std::cout + << " {" + << "\"index\": " + << neighbor.index + << ", \"score\": " + << neighbor.score + << "}"; + + if ( + position + 1 + < neighbors.size() + ) { + std::cout << ","; + } + + std::cout << "\n"; + } + + std::cout + << " ]\n" + << " }"; + + if ( + query_index + 1 + < output.query_count + ) { + std::cout << ","; + } + + std::cout << "\n"; + } + + const std::size_t below_count = + output.query_count + - recognized_count; + + const double coverage = + output.query_count > 0 + ? ( + static_cast( + recognized_count + ) + / static_cast( + output.query_count + ) + * 100.0 + ) + : 0.0; + + std::cout + << " ],\n" + << " \"recognized_count\": " + << recognized_count + << ",\n" + << " \"below_threshold_count\": " + << below_count + << ",\n" + << " \"coverage_percent\": " + << coverage + << "\n" + << "}\n"; +} + + +} // namespace + + +int main( + int argc, + char** argv) +{ + try { + const Arguments arguments = + parse_arguments( + argc, + argv + ); + + const std::size_t query_values = + arguments.query_count + * arguments.dimension; + + const std::size_t database_values = + arguments.database_count + * arguments.dimension; + + const auto queries = + read_float_file( + arguments.queries_path, + query_values + ); + + const auto database = + read_float_file( + arguments.database_path, + database_values + ); + + const auto self_map = + read_self_map( + arguments.self_map_path, + arguments.query_count + ); + + face_knn::SearchOptions options; + + options.dimension = + arguments.dimension; + + options.top_k = + arguments.top_k; + + options.kernel_path = + "kernels/knn_float4_topk.cl"; + + face_knn::SearchOutput output; + + if ( + arguments.backend == "opencl" + ) { + /* + * Il runtime Mali scrive la propria versione + * direttamente su stdout. Durante OpenCL + * deviamo stdout su stderr, affinché l'output + * JSON resti puro. + */ + ScopedStdoutToStderr redirect; + + output = + face_knn::search_opencl( + database, + queries, + arguments.database_count, + arguments.query_count, + options + ); + } else { + output = + face_knn::search_cpu( + database, + queries, + arguments.database_count, + arguments.query_count, + options + ); + } + + write_json( + output, + self_map, + arguments.threshold + ); + + return 0; + } catch (const std::exception& error) { + std::cerr + << "[face_knn] errore: " + << error.what() + << "\n"; + + print_usage(argv[0]); + + return 1; + } +} diff --git a/docs/face-knn-source/src/face_recognition.cc b/docs/face-knn-source/src/face_recognition.cc new file mode 100644 index 0000000..893c28c --- /dev/null +++ b/docs/face-knn-source/src/face_recognition.cc @@ -0,0 +1,144 @@ +#include +#include +#include +#include + +#include "face_engine.h" +#include "face_config.h" + +static constexpr float MATCH_THRESHOLD = 0.363f; + +static float cosine( + const std::vector& a, + const std::vector& b) +{ + if (a.size() != b.size()) + return 0.0f; + + float score = 0.0f; + + for (std::size_t i = 0; i < a.size(); ++i) + score += a[i] * b[i]; + + return score; +} + +static void print_faces( + const std::string& image_path, + const std::vector& faces) +{ + std::cout << "\n" << image_path << "\n"; + std::cout << "Faces: " << faces.size() << "\n"; + + for (std::size_t i = 0; i < faces.size(); ++i) { + const Face& face = faces[i]; + + std::cout + << "\nFace " << i + << " score=" << face.score + << "\n bbox=" + << face.x1 << " " + << face.y1 << " " + << face.x2 << " " + << face.y2 + << "\n"; + + std::cout << " landmarks:\n"; + + for (int j = 0; j < 5; ++j) { + std::cout + << " " + << j + << ": " + << face.kps[j].x + << " " + << face.kps[j].y + << "\n"; + } + } +} + +int main(int argc, char** argv) +{ + if (argc != 2 && argc != 3) { + std::cerr + << "Uso:\n" + << " " << argv[0] + << " image.jpg\n" + << " " << argv[0] + << " reference.jpg query.jpg\n"; + + return 1; + } + + const std::string root = + FACE_CLUSTERING_ROOT; + + const std::string scrfd_model = + root + "/models/SCRFD_500M_KPS_640.rknn"; + + const std::string sface_model = + root + "/models/face_recognition_sface_2021dec.rknn"; + + FaceEngine engine; + + if (!engine.init(scrfd_model, sface_model)) + return 1; + + std::vector faces1; + int image1_width = 0; + int image1_height = 0; + + if (!engine.process_image( + argv[1], + faces1, + image1_width, + image1_height)) { + return 1; + } + + print_faces(argv[1], faces1); + + if (argc == 2) + return 0; + + std::vector faces2; + int image2_width = 0; + int image2_height = 0; + + if (!engine.process_image( + argv[2], + faces2, + image2_width, + image2_height)) { + return 1; + } + + print_faces(argv[2], faces2); + + std::cout + << "\n========================================\n" + << "COSINE SIMILARITY\n" + << "========================================\n"; + + for (std::size_t i = 0; i < faces1.size(); ++i) { + for (std::size_t j = 0; j < faces2.size(); ++j) { + const float similarity = cosine( + faces1[i].embedding, + faces2[j].embedding); + + std::cout + << "ref[" << i + << "] vs query[" << j + << "] = " + << similarity; + + if (similarity >= MATCH_THRESHOLD) + std::cout << " MATCH"; + + std::cout << "\n"; + } + } + + return 0; +} diff --git a/docs/face-knn-source/src/face_scan.cc b/docs/face-knn-source/src/face_scan.cc new file mode 100644 index 0000000..08ddf04 --- /dev/null +++ b/docs/face-knn-source/src/face_scan.cc @@ -0,0 +1,368 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "face_engine.h" + +static constexpr std::size_t EMBEDDING_SIZE = 128; + +static std::filesystem::path executable_path() +{ + std::vector buffer(4096); + + while (true) { + const ssize_t length = readlink( + "/proc/self/exe", + buffer.data(), + buffer.size() - 1); + + if (length < 0) { + throw std::runtime_error( + "Impossibile leggere /proc/self/exe"); + } + + const std::size_t path_length = + static_cast(length); + + if (path_length < buffer.size() - 1) { + buffer[path_length] = '\0'; + + return std::filesystem::path( + buffer.data()); + } + + buffer.resize(buffer.size() * 2); + } +} + +static std::filesystem::path application_root() +{ + /* + * Struttura prevista: + * + * root/ + * bin/face_scan + * lib/librknnrt.so + * models/.rknn + * + * executable_path(): + * root/bin/face_scan + * + * parent_path(): + * root/bin + * + * parent_path().parent_path(): + * root + */ + return executable_path() + .parent_path() + .parent_path(); +} + +static void write_json_string(const std::string& value) +{ + std::cout << '"'; + + for (unsigned char ch : value) { + switch (ch) { + case '"': + std::cout << "\\\""; + break; + + case '\\': + std::cout << "\\\\"; + break; + + case '\b': + std::cout << "\\b"; + break; + + case '\f': + std::cout << "\\f"; + break; + + case '\n': + std::cout << "\\n"; + break; + + case '\r': + std::cout << "\\r"; + break; + + case '\t': + std::cout << "\\t"; + break; + + default: + if (ch < 0x20) { + const char* hex = "0123456789abcdef"; + + std::cout + << "\\u00" + << hex[(ch >> 4) & 0x0f] + << hex[ch & 0x0f]; + } else { + std::cout << static_cast(ch); + } + } + } + + std::cout << '"'; +} + +static bool finite_face(const Face& face) +{ + if (!std::isfinite(face.score) || + !std::isfinite(face.x1) || + !std::isfinite(face.y1) || + !std::isfinite(face.x2) || + !std::isfinite(face.y2)) { + return false; + } + + for (const Point& point : face.kps) { + if (!std::isfinite(point.x) || + !std::isfinite(point.y)) { + return false; + } + } + + if (face.embedding.size() != EMBEDDING_SIZE) + return false; + + for (float value : face.embedding) { + if (!std::isfinite(value)) + return false; + } + + return true; +} + +static void write_face_json( + const Face& face, + std::size_t face_index) +{ + const float width = face.x2 - face.x1; + const float height = face.y2 - face.y1; + + std::cout << "{"; + + std::cout + << "\"face_index\":" + << face_index + << ","; + + std::cout + << "\"score\":" + << face.score + << ","; + + std::cout << "\"bbox\":{"; + + std::cout + << "\"x\":" + << face.x1 + << ","; + + std::cout + << "\"y\":" + << face.y1 + << ","; + + std::cout + << "\"width\":" + << width + << ","; + + std::cout + << "\"height\":" + << height; + + std::cout << "},"; + + std::cout << "\"landmarks\":["; + + for (int i = 0; i < 5; ++i) { + if (i != 0) + std::cout << ","; + + std::cout + << "[" + << face.kps[i].x + << "," + << face.kps[i].y + << "]"; + } + + std::cout << "],"; + + std::cout << "\"embedding\":["; + + for (std::size_t i = 0; + i < face.embedding.size(); + ++i) { + if (i != 0) + std::cout << ","; + + std::cout << face.embedding[i]; + } + + std::cout << "]"; + + std::cout << "}"; +} + +int main(int argc, char** argv) +{ + int rotation_degrees = 0; + const char* image_path = nullptr; + + if (argc == 2) { + image_path = argv[1]; + } else if ( + argc == 4 + && std::string(argv[1]) == "--rotation" + ) { + char* parse_end = nullptr; + + const long parsed_rotation = std::strtol( + argv[2], + &parse_end, + 10); + + if ( + parse_end == argv[2] + || *parse_end != '\0' + || parsed_rotation < -360000 + || parsed_rotation > 360000 + ) { + std::cerr + << "Rotazione non valida: " + << argv[2] + << "\n"; + + return 2; + } + + rotation_degrees = + static_cast(parsed_rotation); + + image_path = argv[3]; + } else { + std::cerr + << "Uso:\n" + << " " << argv[0] + << " image.jpg\n" + << " " << argv[0] + << " --rotation 90 image.jpg\n"; + + return 2; + } + + std::filesystem::path root; + + try { + root = application_root(); + } catch (const std::exception& error) { + std::cerr + << "Impossibile determinare la root applicativa: " + << error.what() + << "\n"; + + return 3; + } + + const std::string scrfd_model = + ( + root + / "models" + / "SCRFD_500M_KPS_640.rknn" + ).string(); + + const std::string sface_model = + ( + root + / "models" + / "face_recognition_sface_2021dec.rknn" + ).string(); + + FaceEngine engine; + + if (!engine.init(scrfd_model, sface_model)) { + std::cerr + << "Impossibile inizializzare FaceEngine\n"; + + return 3; + } + + std::vector faces; + int image_width = 0; + int image_height = 0; + + if (!engine.process_image( + image_path, + faces, + image_width, + image_height, + rotation_degrees)) { + std::cerr + << "Scansione fallita: " + << image_path + << "\n"; + + return 4; + } + + for (std::size_t i = 0; i < faces.size(); ++i) { + if (!finite_face(faces[i])) { + std::cerr + << "Risultato non valido per face_index=" + << i + << "\n"; + + return 5; + } + } + + std::cout << std::setprecision(9); + + std::cout << "{"; + + std::cout << "\"image\":{"; + + std::cout << "\"path\":"; + write_json_string(image_path); + + std::cout + << ",\"width\":" + << image_width + << ",\"height\":" + << image_height + << ",\"rotation\":" + << rotation_degrees; + + std::cout << "},"; + + std::cout << "\"faces\":["; + + for (std::size_t i = 0; i < faces.size(); ++i) { + if (i != 0) + std::cout << ","; + + write_face_json(faces[i], i); + } + + std::cout << "]"; + + std::cout << "}\n"; + + return 0; +} diff --git a/docs/face-knn-source/src/knn_opencl.cc b/docs/face-knn-source/src/knn_opencl.cc new file mode 100644 index 0000000..031a586 --- /dev/null +++ b/docs/face-knn-source/src/knn_opencl.cc @@ -0,0 +1,484 @@ +#include "knn_opencl.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace face_knn { + +SearchOutput search_opencl_impl( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options); + + +namespace { + +bool better_neighbor( + const Neighbor& left, + const Neighbor& right) +{ + if (left.score != right.score) { + return left.score > right.score; + } + + return left.index < right.index; +} + + +void validate_finite_values( + const std::vector& values, + const char* name) +{ + for ( + std::size_t index = 0; + index < values.size(); + ++index + ) { + if (!std::isfinite(values[index])) { + throw std::invalid_argument( + std::string(name) + + " contiene un valore non finito" + ); + } + } +} + + +std::uint32_t effective_top_k( + std::size_t database_count, + std::uint32_t requested_top_k) +{ + return std::min( + requested_top_k, + static_cast( + database_count + ) + ); +} + +} // namespace + + +void validate_input( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options) +{ + if (options.dimension == 0) { + throw std::invalid_argument( + "dimension deve essere maggiore di zero" + ); + } + + if (options.top_k == 0) { + throw std::invalid_argument( + "top_k deve essere maggiore di zero" + ); + } + + if (options.lanes_per_query == 0) { + throw std::invalid_argument( + "lanes_per_query deve essere maggiore di zero" + ); + } + + if (options.chunk_size == 0) { + throw std::invalid_argument( + "chunk_size deve essere maggiore di zero" + ); + } + + if (database_count == 0) { + throw std::invalid_argument( + "database_count deve essere maggiore di zero" + ); + } + + if (query_count == 0) { + throw std::invalid_argument( + "query_count deve essere maggiore di zero" + ); + } + + if ( + database_count + > std::numeric_limits< + std::uint32_t + >::max() + ) { + throw std::invalid_argument( + "database_count supera uint32" + ); + } + + const std::size_t expected_database = + database_count + * static_cast( + options.dimension + ); + + const std::size_t expected_queries = + query_count + * static_cast( + options.dimension + ); + + if (database.size() != expected_database) { + throw std::invalid_argument( + "dimensione database incoerente" + ); + } + + if (queries.size() != expected_queries) { + throw std::invalid_argument( + "dimensione query incoerente" + ); + } + + validate_finite_values( + database, + "database" + ); + + validate_finite_values( + queries, + "queries" + ); +} + + +SearchOutput search_cpu( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options) +{ + validate_input( + database, + queries, + database_count, + query_count, + options + ); + + const std::uint32_t result_top_k = + effective_top_k( + database_count, + options.top_k + ); + + SearchOutput output; + + output.query_count = query_count; + output.database_count = database_count; + output.dimension = options.dimension; + output.top_k = result_top_k; + output.backend = "cpu"; + output.device_name = "CPU"; + + output.neighbors.resize( + query_count + * static_cast( + result_top_k + ) + ); + + const auto started_at = + std::chrono::steady_clock::now(); + + for ( + std::size_t query_index = 0; + query_index < query_count; + ++query_index + ) { + const float* query = + &queries[ + query_index + * options.dimension + ]; + + std::vector candidates; + + candidates.reserve(database_count); + + for ( + std::size_t database_index = 0; + database_index < database_count; + ++database_index + ) { + const float* database_vector = + &database[ + database_index + * options.dimension + ]; + + float score = 0.0f; + + for ( + std::uint32_t component = 0; + component < options.dimension; + ++component + ) { + score += + database_vector[component] + * query[component]; + } + + candidates.push_back({ + score, + static_cast( + database_index + ), + }); + } + + if ( + result_top_k + < candidates.size() + ) { + std::nth_element( + candidates.begin(), + candidates.begin() + + result_top_k, + candidates.end(), + better_neighbor + ); + } + + std::sort( + candidates.begin(), + candidates.begin() + + result_top_k, + better_neighbor + ); + + const std::size_t output_offset = + query_index + * result_top_k; + + for ( + std::uint32_t position = 0; + position < result_top_k; + ++position + ) { + output.neighbors[ + output_offset + position + ] = candidates[position]; + } + } + + const auto completed_at = + std::chrono::steady_clock::now(); + + output.elapsed_ms = + std::chrono::duration< + double, + std::milli + >( + completed_at - started_at + ).count(); + + return output; +} + + +SearchOutput search_opencl( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options) +{ + return search_opencl_impl( + database, + queries, + database_count, + query_count, + options + ); +} + + +std::vector query_neighbors( + const SearchOutput& output, + std::size_t query_index) +{ + if (query_index >= output.query_count) { + throw std::out_of_range( + "query_index fuori intervallo" + ); + } + + const std::size_t begin = + query_index + * output.top_k; + + const std::size_t end = + begin + + output.top_k; + + if (end > output.neighbors.size()) { + throw std::runtime_error( + "layout SearchOutput incoerente" + ); + } + + return { + output.neighbors.begin() + begin, + output.neighbors.begin() + end, + }; +} + + +std::vector exclude_neighbor( + const std::vector& neighbors, + std::uint32_t excluded_index) +{ + std::vector filtered; + + filtered.reserve(neighbors.size()); + + for (const Neighbor& neighbor : neighbors) { + if (neighbor.index == invalid_index) { + continue; + } + + if (neighbor.index == excluded_index) { + continue; + } + + filtered.push_back(neighbor); + } + + return filtered; +} + + +ValidationResult compare_outputs( + const SearchOutput& expected, + const SearchOutput& actual, + float score_tolerance, + float recognition_threshold) +{ + if (score_tolerance < 0.0f) { + throw std::invalid_argument( + "score_tolerance non valida" + ); + } + + if ( + expected.query_count + != actual.query_count + || expected.database_count + != actual.database_count + || expected.dimension + != actual.dimension + || expected.top_k + != actual.top_k + || expected.neighbors.size() + != actual.neighbors.size() + ) { + throw std::invalid_argument( + "Output kNN non confrontabili" + ); + } + + ValidationResult result; + + result.positions = + expected.neighbors.size(); + + for ( + std::size_t index = 0; + index < result.positions; + ++index + ) { + const Neighbor& expected_neighbor = + expected.neighbors[index]; + + const Neighbor& actual_neighbor = + actual.neighbors[index]; + + if ( + expected_neighbor.index + == actual_neighbor.index + ) { + result.exact_index_matches += 1; + } + + const float error = std::fabs( + expected_neighbor.score + - actual_neighbor.score + ); + + result.maximum_score_error = + std::max( + result.maximum_score_error, + error + ); + } + + for ( + std::size_t query_index = 0; + query_index < expected.query_count; + ++query_index + ) { + const auto expected_neighbors = + query_neighbors( + expected, + query_index + ); + + const auto actual_neighbors = + query_neighbors( + actual, + query_index + ); + + const bool expected_recognized = + !expected_neighbors.empty() + && expected_neighbors.front().score + >= recognition_threshold; + + const bool actual_recognized = + !actual_neighbors.empty() + && actual_neighbors.front().score + >= recognition_threshold; + + if ( + expected_recognized + == actual_recognized + ) { + result.decision_matches += 1; + } + } + + result.exact_indices = + result.exact_index_matches + == result.positions; + + result.decisions_equal = + result.decision_matches + == expected.query_count; + + if ( + result.maximum_score_error + > score_tolerance + ) { + result.decisions_equal = false; + } + + return result; +} + +} // namespace face_knn diff --git a/docs/face-knn-source/src/knn_opencl_backend.cc b/docs/face-knn-source/src/knn_opencl_backend.cc new file mode 100644 index 0000000..46a12c2 --- /dev/null +++ b/docs/face-knn-source/src/knn_opencl_backend.cc @@ -0,0 +1,944 @@ +#define CL_TARGET_OPENCL_VERSION 120 + +#include + +#include "knn_opencl.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace face_knn { +namespace { + +void check_cl( + cl_int status, + const char* operation) +{ + if (status != CL_SUCCESS) { + throw std::runtime_error( + std::string(operation) + + ": OpenCL status " + + std::to_string(status) + ); + } +} + + +std::string device_string( + cl_device_id device, + cl_device_info property) +{ + size_t size = 0; + + check_cl( + clGetDeviceInfo( + device, + property, + 0, + nullptr, + &size + ), + "clGetDeviceInfo size" + ); + + std::string value( + size, + '\0' + ); + + check_cl( + clGetDeviceInfo( + device, + property, + size, + value.data(), + nullptr + ), + "clGetDeviceInfo value" + ); + + while ( + !value.empty() + && value.back() == '\0' + ) { + value.pop_back(); + } + + return value; +} + + +cl_device_id find_mali_device( + std::string& device_name) +{ + cl_uint platform_count = 0; + + check_cl( + clGetPlatformIDs( + 0, + nullptr, + &platform_count + ), + "clGetPlatformIDs count" + ); + + if (platform_count == 0) { + throw std::runtime_error( + "Nessuna piattaforma OpenCL" + ); + } + + std::vector platforms( + platform_count + ); + + check_cl( + clGetPlatformIDs( + platform_count, + platforms.data(), + nullptr + ), + "clGetPlatformIDs list" + ); + + cl_device_id fallback = nullptr; + std::string fallback_name; + + for ( + cl_platform_id platform + : platforms + ) { + cl_uint device_count = 0; + + const cl_int status = + clGetDeviceIDs( + platform, + CL_DEVICE_TYPE_GPU, + 0, + nullptr, + &device_count + ); + + if ( + status != CL_SUCCESS + || device_count == 0 + ) { + continue; + } + + std::vector devices( + device_count + ); + + check_cl( + clGetDeviceIDs( + platform, + CL_DEVICE_TYPE_GPU, + device_count, + devices.data(), + nullptr + ), + "clGetDeviceIDs list" + ); + + for ( + cl_device_id device + : devices + ) { + const std::string name = + device_string( + device, + CL_DEVICE_NAME + ); + + const std::string vendor = + device_string( + device, + CL_DEVICE_VENDOR + ); + + const std::string full_name = + vendor + " / " + name; + + if (fallback == nullptr) { + fallback = device; + fallback_name = full_name; + } + + if ( + name.find("Mali-G610") + != std::string::npos + ) { + device_name = full_name; + return device; + } + } + } + + if (fallback != nullptr) { + device_name = fallback_name; + + std::cerr + << "[face_knn] Mali-G610 non trovata; " + << "uso GPU OpenCL: " + << fallback_name + << "\n"; + + return fallback; + } + + throw std::runtime_error( + "Nessuna GPU OpenCL disponibile" + ); +} + + +std::string read_text_file( + const std::string& path) +{ + std::ifstream input(path); + + if (!input) { + throw std::runtime_error( + "Kernel OpenCL non leggibile: " + + path + ); + } + + const std::string content{ + std::istreambuf_iterator( + input + ), + std::istreambuf_iterator() + }; + + if (content.empty()) { + throw std::runtime_error( + "Kernel OpenCL vuoto: " + + path + ); + } + + return content; +} + + +bool better_neighbor( + const Neighbor& left, + const Neighbor& right) +{ + if (left.score != right.score) { + return left.score > right.score; + } + + return left.index < right.index; +} + + +Neighbor invalid_neighbor() +{ + return { + -std::numeric_limits::infinity(), + invalid_index, + }; +} + + +void merge_top_k( + const std::vector& chunk_indices, + const std::vector& chunk_scores, + std::size_t chunk_offset, + std::size_t query_count, + std::uint32_t top_k, + std::vector& global_result) +{ + for ( + std::size_t query_index = 0; + query_index < query_count; + ++query_index + ) { + const std::size_t result_offset = + query_index + * top_k; + + std::vector candidates; + + candidates.reserve( + static_cast( + top_k + ) * 2 + ); + + for ( + std::uint32_t position = 0; + position < top_k; + ++position + ) { + const Neighbor& current = + global_result[ + result_offset + + position + ]; + + if ( + current.index + != invalid_index + ) { + candidates.push_back( + current + ); + } + } + + for ( + std::uint32_t position = 0; + position < top_k; + ++position + ) { + const std::size_t chunk_position = + result_offset + + position; + + const std::uint32_t local_index = + chunk_indices[ + chunk_position + ]; + + if ( + local_index + == invalid_index + ) { + continue; + } + + candidates.push_back({ + chunk_scores[ + chunk_position + ], + static_cast( + chunk_offset + + local_index + ), + }); + } + + std::sort( + candidates.begin(), + candidates.end(), + better_neighbor + ); + + for ( + std::uint32_t position = 0; + position < top_k; + ++position + ) { + Neighbor value = + invalid_neighbor(); + + if ( + position + < candidates.size() + ) { + value = + candidates[position]; + } + + global_result[ + result_offset + + position + ] = value; + } + } +} + + +struct OpenCLResources { + cl_context context = nullptr; + + cl_command_queue queue = nullptr; + + cl_program program = nullptr; + + cl_kernel kernel = nullptr; + + cl_mem database_buffer = nullptr; + + cl_mem query_buffer = nullptr; + + cl_mem index_buffer = nullptr; + + cl_mem score_buffer = nullptr; + + ~OpenCLResources() + { + if (score_buffer != nullptr) { + clReleaseMemObject( + score_buffer + ); + } + + if (index_buffer != nullptr) { + clReleaseMemObject( + index_buffer + ); + } + + if (query_buffer != nullptr) { + clReleaseMemObject( + query_buffer + ); + } + + if (database_buffer != nullptr) { + clReleaseMemObject( + database_buffer + ); + } + + if (kernel != nullptr) { + clReleaseKernel(kernel); + } + + if (program != nullptr) { + clReleaseProgram(program); + } + + if (queue != nullptr) { + clReleaseCommandQueue(queue); + } + + if (context != nullptr) { + clReleaseContext(context); + } + } +}; + + +std::string build_log( + cl_program program, + cl_device_id device) +{ + size_t size = 0; + + clGetProgramBuildInfo( + program, + device, + CL_PROGRAM_BUILD_LOG, + 0, + nullptr, + &size + ); + + std::string value( + size, + '\0' + ); + + if (size > 0) { + clGetProgramBuildInfo( + program, + device, + CL_PROGRAM_BUILD_LOG, + size, + value.data(), + nullptr + ); + } + + while ( + !value.empty() + && value.back() == '\0' + ) { + value.pop_back(); + } + + return value; +} + +} // namespace + + +SearchOutput search_opencl_impl( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options) +{ + validate_input( + database, + queries, + database_count, + query_count, + options + ); + + if ( + query_count + > std::numeric_limits< + std::uint32_t + >::max() + ) { + throw std::invalid_argument( + "query_count supera uint32" + ); + } + + const std::uint32_t top_k = + std::min( + options.top_k, + static_cast( + database_count + ) + ); + + const std::string kernel_path = + options.kernel_path.empty() + ? "kernels/knn_float4_topk.cl" + : options.kernel_path; + + const std::string kernel_source = + read_text_file( + kernel_path + ); + + std::string device_name; + + const cl_device_id device = + find_mali_device( + device_name + ); + + std::cerr + << "[face_knn] OpenCL GPU: " + << device_name + << "\n"; + + OpenCLResources resources; + + cl_int status = CL_SUCCESS; + + resources.context = + clCreateContext( + nullptr, + 1, + &device, + nullptr, + nullptr, + &status + ); + + check_cl( + status, + "clCreateContext" + ); + + resources.queue = + clCreateCommandQueue( + resources.context, + device, + 0, + &status + ); + + check_cl( + status, + "clCreateCommandQueue" + ); + + const char* source_pointer = + kernel_source.c_str(); + + const size_t source_size = + kernel_source.size(); + + resources.program = + clCreateProgramWithSource( + resources.context, + 1, + &source_pointer, + &source_size, + &status + ); + + check_cl( + status, + "clCreateProgramWithSource" + ); + + const std::string build_options = + "-cl-fast-relaxed-math" + " -D DIM=" + + std::to_string( + options.dimension + ) + + " -D TOPK=" + + std::to_string(top_k) + + " -D QUERY_COUNT=" + + std::to_string(query_count) + + " -D LANES_PER_QUERY=" + + std::to_string( + options.lanes_per_query + ); + + status = clBuildProgram( + resources.program, + 1, + &device, + build_options.c_str(), + nullptr, + nullptr + ); + + if (status != CL_SUCCESS) { + throw std::runtime_error( + "clBuildProgram fallito: " + + build_log( + resources.program, + device + ) + ); + } + + resources.kernel = + clCreateKernel( + resources.program, + "knn", + &status + ); + + check_cl( + status, + "clCreateKernel" + ); + + const std::size_t chunk_capacity = + std::min( + options.chunk_size, + database_count + ); + + resources.database_buffer = + clCreateBuffer( + resources.context, + CL_MEM_READ_ONLY, + chunk_capacity + * options.dimension + * sizeof(float), + nullptr, + &status + ); + + check_cl( + status, + "clCreateBuffer database" + ); + + resources.query_buffer = + clCreateBuffer( + resources.context, + CL_MEM_READ_ONLY + | CL_MEM_COPY_HOST_PTR, + queries.size() + * sizeof(float), + const_cast( + queries.data() + ), + &status + ); + + check_cl( + status, + "clCreateBuffer queries" + ); + + const std::size_t result_count = + query_count + * static_cast( + top_k + ); + + std::vector chunk_indices( + result_count, + invalid_index + ); + + std::vector chunk_scores( + result_count, + -std::numeric_limits::infinity() + ); + + resources.index_buffer = + clCreateBuffer( + resources.context, + CL_MEM_WRITE_ONLY, + result_count + * sizeof(std::uint32_t), + nullptr, + &status + ); + + check_cl( + status, + "clCreateBuffer indices" + ); + + resources.score_buffer = + clCreateBuffer( + resources.context, + CL_MEM_WRITE_ONLY, + result_count + * sizeof(float), + nullptr, + &status + ); + + check_cl( + status, + "clCreateBuffer scores" + ); + + check_cl( + clSetKernelArg( + resources.kernel, + 0, + sizeof( + resources.database_buffer + ), + &resources.database_buffer + ), + "clSetKernelArg database" + ); + + check_cl( + clSetKernelArg( + resources.kernel, + 1, + sizeof( + resources.query_buffer + ), + &resources.query_buffer + ), + "clSetKernelArg queries" + ); + + check_cl( + clSetKernelArg( + resources.kernel, + 2, + sizeof( + resources.index_buffer + ), + &resources.index_buffer + ), + "clSetKernelArg indices" + ); + + check_cl( + clSetKernelArg( + resources.kernel, + 3, + sizeof( + resources.score_buffer + ), + &resources.score_buffer + ), + "clSetKernelArg scores" + ); + + SearchOutput output; + + output.query_count = + query_count; + + output.database_count = + database_count; + + output.dimension = + options.dimension; + + output.top_k = + top_k; + + output.backend = + "opencl"; + + output.device_name = + device_name; + + output.neighbors.assign( + result_count, + invalid_neighbor() + ); + + const std::size_t global_size = + query_count + * options.lanes_per_query; + + const std::size_t local_size = + options.lanes_per_query; + + const std::size_t chunk_count = + ( + database_count + + options.chunk_size + - 1 + ) + / options.chunk_size; + + std::cerr + << "[face_knn] kernel_path=" + << kernel_path + << "\n" + << "[face_knn] build_options=" + << build_options + << "\n" + << "[face_knn] chunk_size=" + << chunk_capacity + << "\n" + << "[face_knn] chunks=" + << chunk_count + << "\n"; + + const auto started_at = + std::chrono::steady_clock::now(); + + for ( + std::size_t chunk_offset = 0; + chunk_offset < database_count; + chunk_offset += options.chunk_size + ) { + const std::size_t chunk_size = + std::min( + options.chunk_size, + database_count + - chunk_offset + ); + + const float* chunk_pointer = + &database[ + chunk_offset + * options.dimension + ]; + + check_cl( + clEnqueueWriteBuffer( + resources.queue, + resources.database_buffer, + CL_TRUE, + 0, + chunk_size + * options.dimension + * sizeof(float), + chunk_pointer, + 0, + nullptr, + nullptr + ), + "clEnqueueWriteBuffer database" + ); + + const std::uint32_t kernel_count = + static_cast( + chunk_size + ); + + check_cl( + clSetKernelArg( + resources.kernel, + 4, + sizeof(kernel_count), + &kernel_count + ), + "clSetKernelArg database count" + ); + + check_cl( + clEnqueueNDRangeKernel( + resources.queue, + resources.kernel, + 1, + nullptr, + &global_size, + &local_size, + 0, + nullptr, + nullptr + ), + "clEnqueueNDRangeKernel" + ); + + check_cl( + clFinish( + resources.queue + ), + "clFinish" + ); + + check_cl( + clEnqueueReadBuffer( + resources.queue, + resources.index_buffer, + CL_TRUE, + 0, + chunk_indices.size() + * sizeof(std::uint32_t), + chunk_indices.data(), + 0, + nullptr, + nullptr + ), + "clEnqueueReadBuffer indices" + ); + + check_cl( + clEnqueueReadBuffer( + resources.queue, + resources.score_buffer, + CL_TRUE, + 0, + chunk_scores.size() + * sizeof(float), + chunk_scores.data(), + 0, + nullptr, + nullptr + ), + "clEnqueueReadBuffer scores" + ); + + merge_top_k( + chunk_indices, + chunk_scores, + chunk_offset, + query_count, + top_k, + output.neighbors + ); + } + + const auto completed_at = + std::chrono::steady_clock::now(); + + output.elapsed_ms = + std::chrono::duration< + double, + std::milli + >( + completed_at - started_at + ).count(); + + return output; +} + + +} // namespace face_knn diff --git a/docs/face-scan-source/COMPILAZIONE_FACE_SCAN.md b/docs/face-scan-source/COMPILAZIONE_FACE_SCAN.md new file mode 100644 index 0000000..d333a80 --- /dev/null +++ b/docs/face-scan-source/COMPILAZIONE_FACE_SCAN.md @@ -0,0 +1,281 @@ +# Face Scan RK3588 - Guida ai sorgenti e compilazione + +## Repository sorgente + +Repository originale identificato: + +```text +/home/nvme/dockerdata/prove/face-clustering-rk3588-knn-opencl-validated +``` + +Questo repository contiene il motore utilizzato per il Face Scan dei progetti: + +```text +s141js → s190js +``` + +--- + +## Scopo del programma + +Il binario: + +```text +bin/face_scan +``` + +esegue: + +- rilevamento dei volti nelle fotografie; +- estrazione degli embedding facciali; +- generazione dell'output JSON utilizzato successivamente dal sistema di riconoscimento. + +Il riconoscimento delle persone non viene eseguito da `face_scan`. + +Il riconoscimento viene effettuato successivamente dal programma: + +```text +bin/face_knn +``` + +che utilizza gli embedding generati da `face_scan`. + +--- + +## Sorgenti principali + +### Main Face Scan + +```text +src/face_scan.cc +``` + +Si occupa di: + +- caricamento immagine; +- avvio scansione; +- generazione output JSON. + +### Motore RKNN + +```text +src/face_engine.cc +``` + +Gestisce: + +- caricamento modello RKNN; +- inferenza tramite NPU Rockchip; +- estrazione embedding. + +### Header + +```text +include/face_engine.h +include/face_config.h +``` + +--- + +## Binari generati + +```text +bin/face_scan +bin/face_recognition +``` + +--- + +## Modelli utilizzati + +I modelli sono contenuti nella directory: + +```text +models/ +``` + +Verificare i modelli disponibili con: + +```bash +find models -type f +``` + +--- + +## Librerie richieste + +Verifica: + +```bash +ldd bin/face_scan +``` + +Dipendenze identificate: + +```text +librknnrt.so +libpthread.so +libdl.so +libstdc++.so.6 +libgcc_s.so.1 +libc.so.6 +libm.so.6 +``` + +Dipendenza fondamentale: + +```text +lib/librknnrt.so +``` + +Runtime NPU Rockchip. + +--- + +## Compilazione + +Pulizia: + +```bash +make clean +``` + +Compilazione: + +```bash +make +``` + +Il Makefile genera: + +```text +bin/face_scan +bin/face_recognition +``` + +--- + +## Comandi di verifica + +Verifica dipendenze: + +```bash +ldd bin/face_scan +``` + +Verifica hash: + +```bash +sha256sum bin/face_scan +``` + +Visualizza simboli: + +```bash +nm -C bin/face_scan | head +``` + +--- + +## Hardware target + +Scheda: + +```text +Orange Pi 5 Plus +``` + +SoC: + +```text +Rockchip RK3588 +``` + +Acceleratore: + +```text +Rockchip NPU +``` + +Runtime: + +```text +RKNN Runtime +``` + +--- + +## Relazione con Face KNN + +Pipeline completa: + +```text +Foto + ↓ +face_scan + ↓ +embedding facciali + ↓ +face_knn + ↓ +riconoscimento persona + ↓ +database + ↓ +gallery +``` + +Face Scan: + +```text +NPU Rockchip (RKNN) +``` + +Face KNN: + +```text +CPU/OpenCL +Mali-G610 +``` + +--- + +## Cartelle da conservare + +Per poter ricompilare il Face Scan in futuro conservare: + +```text +src/ +include/ +models/ +lib/ +Makefile +``` + +In particolare: + +```text +src/face_scan.cc +src/face_engine.cc +include/face_engine.h +include/face_config.h +lib/librknnrt.so +``` + +--- + +## Note storiche + +Il repository: + +```text +face-clustering-rk3588-knn-opencl-validated +``` + +è stato identificato come repository sorgente originale del motore Face Scan utilizzato dai progetti s141js → s190js. + +Data documentazione: + +```text +2026-10-26 +``` diff --git a/docs/face-scan-source/Makefile b/docs/face-scan-source/Makefile new file mode 100644 index 0000000..de64934 --- /dev/null +++ b/docs/face-scan-source/Makefile @@ -0,0 +1,99 @@ +CXX ?= g++ + +ROOT := $(abspath .) + +CXXFLAGS := \ + -std=c++17 \ + -O2 \ + -Wall \ + -Wextra \ + -Wpedantic \ + -Iinclude \ + -DFACE_CLUSTERING_ROOT=\"$(ROOT)\" + +LDFLAGS := \ + -L$(ROOT)/lib \ + -Wl,-rpath,'$$ORIGIN/../lib' + +LDLIBS := \ + -lrknnrt \ + -ldl \ + -lpthread + +BIN_DIR := bin +BUILD_DIR := build + +ENGINE_SOURCE := src/face_engine.cc +ENGINE_OBJECT := $(BUILD_DIR)/face_engine.o + +FACE_RECOGNITION_SOURCE := src/face_recognition.cc +FACE_RECOGNITION_OBJECT := $(BUILD_DIR)/face_recognition.o +FACE_RECOGNITION_BINARY := $(BIN_DIR)/face_recognition + +FACE_SCAN_SOURCE := src/face_scan.cc +FACE_SCAN_OBJECT := $(BUILD_DIR)/face_scan.o +FACE_SCAN_BINARY := $(BIN_DIR)/face_scan + +.PHONY: all clean test info + +all: \ + $(FACE_RECOGNITION_BINARY) \ + $(FACE_SCAN_BINARY) + +$(BUILD_DIR): + mkdir -p $(BUILD_DIR) + +$(BIN_DIR): + mkdir -p $(BIN_DIR) + +$(ENGINE_OBJECT): $(ENGINE_SOURCE) include/face_engine.h | $(BUILD_DIR) + $(CXX) $(CXXFLAGS) -c $< -o $@ + +$(FACE_RECOGNITION_OBJECT): \ + $(FACE_RECOGNITION_SOURCE) \ + include/face_engine.h \ + include/face_config.h \ + | $(BUILD_DIR) + $(CXX) $(CXXFLAGS) -c $< -o $@ + +$(FACE_SCAN_OBJECT): \ + $(FACE_SCAN_SOURCE) \ + include/face_engine.h \ + include/face_config.h \ + | $(BUILD_DIR) + $(CXX) $(CXXFLAGS) -c $< -o $@ + +$(FACE_RECOGNITION_BINARY): \ + $(ENGINE_OBJECT) \ + $(FACE_RECOGNITION_OBJECT) \ + | $(BIN_DIR) + $(CXX) $^ $(LDFLAGS) $(LDLIBS) -o $@ + +$(FACE_SCAN_BINARY): \ + $(ENGINE_OBJECT) \ + $(FACE_SCAN_OBJECT) \ + | $(BIN_DIR) + $(CXX) $^ $(LDFLAGS) $(LDLIBS) -o $@ + +test: all + ./bin/face_recognition \ + test/test.jpg \ + test/test3f.jpg + ./bin/face_scan \ + test/test3f.jpg \ + > /tmp/face_scan_test.json + python3 -m json.tool \ + /tmp/face_scan_test.json \ + > /dev/null + @echo "Tests completed successfully" + +info: + @echo "ROOT=$(ROOT)" + @echo "CXX=$(CXX)" + @echo "CXXFLAGS=$(CXXFLAGS)" + @echo "LDFLAGS=$(LDFLAGS)" + +clean: + rm -rf $(BUILD_DIR) + rm -f $(FACE_RECOGNITION_BINARY) + rm -f $(FACE_SCAN_BINARY) diff --git a/docs/face-scan-source/include/face_config.h b/docs/face-scan-source/include/face_config.h new file mode 100644 index 0000000..bd1f0f5 --- /dev/null +++ b/docs/face-scan-source/include/face_config.h @@ -0,0 +1,5 @@ +#pragma once + +#ifndef FACE_CLUSTERING_ROOT +#define FACE_CLUSTERING_ROOT "." +#endif diff --git a/docs/face-scan-source/include/face_engine.h b/docs/face-scan-source/include/face_engine.h new file mode 100644 index 0000000..b646335 --- /dev/null +++ b/docs/face-scan-source/include/face_engine.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +struct Point { + float x = 0.0f; + float y = 0.0f; +}; + +struct Face { + float score = 0.0f; + + float x1 = 0.0f; + float y1 = 0.0f; + float x2 = 0.0f; + float y2 = 0.0f; + + Point kps[5]{}; + + // SFace: embedding L2-normalizzato da 128 float32. + std::vector embedding; +}; + +class FaceEngine { +public: + FaceEngine(); + ~FaceEngine(); + + FaceEngine(const FaceEngine&) = delete; + FaceEngine& operator=(const FaceEngine&) = delete; + + bool init( + const std::string& scrfd_model, + const std::string& sface_model); + + bool process_image( + const std::string& image_path, + std::vector& faces, + int& image_width, + int& image_height, + int rotation_degrees = 0); + +private: + struct Impl; + Impl* impl_; +}; diff --git a/docs/face-scan-source/include/knn_opencl.h b/docs/face-scan-source/include/knn_opencl.h new file mode 100644 index 0000000..db9722d --- /dev/null +++ b/docs/face-scan-source/include/knn_opencl.h @@ -0,0 +1,121 @@ +#ifndef FACE_CLUSTERING_KNN_OPENCL_H +#define FACE_CLUSTERING_KNN_OPENCL_H + +#include +#include +#include +#include + +namespace face_knn { + +constexpr std::uint32_t invalid_index = + 0xffffffffu; + + +struct Neighbor { + float score = 0.0f; + + std::uint32_t index = + invalid_index; +}; + + +struct SearchOptions { + std::uint32_t dimension = 128; + + std::uint32_t top_k = 1; + + std::uint32_t lanes_per_query = 8; + + std::size_t chunk_size = 50000; + + std::string kernel_path; +}; + + +struct SearchOutput { + std::size_t query_count = 0; + + std::size_t database_count = 0; + + std::uint32_t dimension = 0; + + std::uint32_t top_k = 0; + + std::string backend; + + std::string device_name; + + double elapsed_ms = 0.0; + + /* + * Layout: + * + * neighbors[ + * query_index * top_k + * + neighbor_position + * ] + */ + std::vector neighbors; +}; + + +struct ValidationResult { + std::size_t positions = 0; + + std::size_t exact_index_matches = 0; + + std::size_t decision_matches = 0; + + float maximum_score_error = 0.0f; + + bool exact_indices = false; + + bool decisions_equal = false; +}; + + +void validate_input( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options); + + +SearchOutput search_cpu( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options); + + +SearchOutput search_opencl( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options); + + +ValidationResult compare_outputs( + const SearchOutput& expected, + const SearchOutput& actual, + float score_tolerance, + float recognition_threshold); + + +std::vector query_neighbors( + const SearchOutput& output, + std::size_t query_index); + + +std::vector exclude_neighbor( + const std::vector& neighbors, + std::uint32_t excluded_index); + + +} // namespace face_knn + +#endif diff --git a/docs/face-scan-source/include/rknn_api.h b/docs/face-scan-source/include/rknn_api.h new file mode 100644 index 0000000..6b294d2 --- /dev/null +++ b/docs/face-scan-source/include/rknn_api.h @@ -0,0 +1,804 @@ +/**************************************************************************** +* +* Copyright (c) 2017 - 2022 by Rockchip Corp. All rights reserved. +* +* The material in this file is confidential and contains trade secrets +* of Rockchip Corporation. This is proprietary information owned by +* Rockchip Corporation. No part of this work may be disclosed, +* reproduced, copied, transmitted, or used in any way for any purpose, +* without the express written permission of Rockchip Corporation. +* +*****************************************************************************/ + + +#ifndef _RKNN_API_H +#define _RKNN_API_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +/* + Definition of extended flag for rknn_init. +*/ +/* set high priority context. */ +#define RKNN_FLAG_PRIOR_HIGH 0x00000000 + +/* set medium priority context */ +#define RKNN_FLAG_PRIOR_MEDIUM 0x00000001 + +/* set low priority context. */ +#define RKNN_FLAG_PRIOR_LOW 0x00000002 + +/* asynchronous mode. + when enable, rknn_outputs_get will not block for too long because it directly retrieves the result of + the previous frame which can increase the frame rate on single-threaded mode, but at the cost of + rknn_outputs_get not retrieves the result of the current frame. + in multi-threaded mode you do not need to turn this mode on. */ +#define RKNN_FLAG_ASYNC_MASK 0x00000004 + +/* collect performance mode. + when enable, you can get detailed performance reports via rknn_query(ctx, RKNN_QUERY_PERF_DETAIL, ...), + but it will reduce the frame rate. */ +#define RKNN_FLAG_COLLECT_PERF_MASK 0x00000008 + +/* allocate all memory in outside, includes weight/internal/inputs/outputs */ +#define RKNN_FLAG_MEM_ALLOC_OUTSIDE 0x00000010 + +/* weight sharing with the same network structure */ +#define RKNN_FLAG_SHARE_WEIGHT_MEM 0x00000020 + +/* send fence fd from outside */ +#define RKNN_FLAG_FENCE_IN_OUTSIDE 0x00000040 + +/* get fence fd from inside */ +#define RKNN_FLAG_FENCE_OUT_OUTSIDE 0x00000080 + +/* dummy init flag: could only get total_weight_size and total_internal_size by rknn_query*/ +#define RKNN_FLAG_COLLECT_MODEL_INFO_ONLY 0x00000100 + +/* allocate internal memory in outside */ +#define RKNN_FLAG_INTERNAL_ALLOC_OUTSIDE 0x00000200 + +/* set GPU as the preferred execution backend When the operator is not supported by the NPU */ +#define RKNN_FLAG_EXECUTE_FALLBACK_PRIOR_DEVICE_GPU 0x00000400 + +/* enable allocate sram type buffers */ +#define RKNN_FLAG_ENABLE_SRAM 0x00000800 + +/* sram type buffers are shared among different contexts */ +#define RKNN_FLAG_SHARE_SRAM 0x00001000 + +/* default nice -19, this flag can disable default priority */ +#define RKNN_FLAG_DISABLE_PROC_HIGH_PRIORITY 0x00002000 + +/* don't flush input buffer cache, the user must ensure that the input tensor has flushed the cache before calling rknn_run. +!!! Don't use this flags when you call rknn_inputs_set() to set input data. */ +#define RKNN_FLAG_DISABLE_FLUSH_INPUT_MEM_CACHE 0x00004000 + +/* Don't invalid output buffer cache. + Users cannot directly access output_mem->virt_addr, + which will cause cache consistency problems. + If you want to use output_mem->virt_addr, + you must use rknn_mem_sync (ctx, mem, RKNN_MEMORY_SYNC_FROM_DEVICE) to flush the cache. + This flags is generally used when the output data of the NPU is not accessed by the CPU, + but is accessed by the GPU or RGA to reduce the time required to flush the cache. + !!! Don't use this flags when you call rknn_outputs_get() to get output data.*/ +#define RKNN_FLAG_DISABLE_FLUSH_OUTPUT_MEM_CACHE 0x00008000 + +/* This flag is used when the model data buffer is allocated by NPU, and can be accessed by NPU directly. */ +#define RKNN_FLAG_MODEL_BUFFER_ZERO_COPY 0x00010000 + +/* This flag is a memory allocation flag, which is used in rknn_create_mem2() when no context is available. */ +#define RKNN_MEM_FLAG_ALLOC_NO_CONTEXT 0x00020000 + + +/* + Error code returned by the RKNN API. +*/ +#define RKNN_SUCC 0 /* execute succeed. */ +#define RKNN_ERR_FAIL -1 /* execute failed. */ +#define RKNN_ERR_TIMEOUT -2 /* execute timeout. */ +#define RKNN_ERR_DEVICE_UNAVAILABLE -3 /* device is unavailable. */ +#define RKNN_ERR_MALLOC_FAIL -4 /* memory malloc fail. */ +#define RKNN_ERR_PARAM_INVALID -5 /* parameter is invalid. */ +#define RKNN_ERR_MODEL_INVALID -6 /* model is invalid. */ +#define RKNN_ERR_CTX_INVALID -7 /* context is invalid. */ +#define RKNN_ERR_INPUT_INVALID -8 /* input is invalid. */ +#define RKNN_ERR_OUTPUT_INVALID -9 /* output is invalid. */ +#define RKNN_ERR_DEVICE_UNMATCH -10 /* the device is unmatch, please update rknn sdk + and npu driver/firmware. */ +#define RKNN_ERR_INCOMPATILE_PRE_COMPILE_MODEL -11 /* This RKNN model use pre_compile mode, but not compatible with current driver. */ +#define RKNN_ERR_INCOMPATILE_OPTIMIZATION_LEVEL_VERSION -12 /* This RKNN model set optimization level, but not compatible with current driver. */ +#define RKNN_ERR_TARGET_PLATFORM_UNMATCH -13 /* This RKNN model set target platform, but not compatible with current platform. */ + +/* + Definition for tensor +*/ +#define RKNN_MAX_DIMS 16 /* maximum dimension of tensor. */ +#define RKNN_MAX_NUM_CHANNEL 15 /* maximum channel number of input tensor. */ +#define RKNN_MAX_NAME_LEN 256 /* maximum name lenth of tensor. */ +#define RKNN_MAX_DYNAMIC_SHAPE_NUM 512 /* maximum number of dynamic shape for each input. */ + +#ifdef __arm__ +typedef uint32_t rknn_context; +#else +typedef uint64_t rknn_context; +#endif + + +/* + The query command for rknn_query +*/ +typedef enum _rknn_query_cmd { + RKNN_QUERY_IN_OUT_NUM = 0, /* query the number of input & output tensor. */ + RKNN_QUERY_INPUT_ATTR = 1, /* query the attribute of input tensor. */ + RKNN_QUERY_OUTPUT_ATTR = 2, /* query the attribute of output tensor. */ + RKNN_QUERY_PERF_DETAIL = 3, /* query the detail performance, need set + RKNN_FLAG_COLLECT_PERF_MASK when call rknn_init, + this query needs to be valid after rknn_outputs_get. */ + RKNN_QUERY_PERF_RUN = 4, /* query the time of run, + this query needs to be valid after rknn_outputs_get. */ + RKNN_QUERY_SDK_VERSION = 5, /* query the sdk & driver version */ + + RKNN_QUERY_MEM_SIZE = 6, /* query the weight & internal memory size */ + RKNN_QUERY_CUSTOM_STRING = 7, /* query the custom string */ + + RKNN_QUERY_NATIVE_INPUT_ATTR = 8, /* query the attribute of native input tensor. */ + RKNN_QUERY_NATIVE_OUTPUT_ATTR = 9, /* query the attribute of native output tensor. */ + + RKNN_QUERY_NATIVE_NC1HWC2_INPUT_ATTR = 8, /* query the attribute of native input tensor. */ + RKNN_QUERY_NATIVE_NC1HWC2_OUTPUT_ATTR = 9, /* query the attribute of native output tensor. */ + + RKNN_QUERY_NATIVE_NHWC_INPUT_ATTR = 10, /* query the attribute of native input tensor. */ + RKNN_QUERY_NATIVE_NHWC_OUTPUT_ATTR = 11, /* query the attribute of native output tensor. */ + + RKNN_QUERY_DEVICE_MEM_INFO = 12, /* query the attribute of rknn memory information. */ + + RKNN_QUERY_INPUT_DYNAMIC_RANGE = 13, /* query the dynamic shape range of rknn input tensor. */ + RKNN_QUERY_CURRENT_INPUT_ATTR = 14, /* query the current shape of rknn input tensor, only valid for dynamic rknn model*/ + RKNN_QUERY_CURRENT_OUTPUT_ATTR = 15, /* query the current shape of rknn output tensor, only valid for dynamic rknn model*/ + + RKNN_QUERY_CURRENT_NATIVE_INPUT_ATTR = 16, /* query the current native shape of rknn input tensor, only valid for dynamic rknn model*/ + RKNN_QUERY_CURRENT_NATIVE_OUTPUT_ATTR = 17, /* query the current native shape of rknn output tensor, only valid for dynamic rknn model*/ + + + RKNN_QUERY_CMD_MAX +} rknn_query_cmd; + +/* + the tensor data type. +*/ +typedef enum _rknn_tensor_type { + RKNN_TENSOR_FLOAT32 = 0, /* data type is float32. */ + RKNN_TENSOR_FLOAT16, /* data type is float16. */ + RKNN_TENSOR_INT8, /* data type is int8. */ + RKNN_TENSOR_UINT8, /* data type is uint8. */ + RKNN_TENSOR_INT16, /* data type is int16. */ + RKNN_TENSOR_UINT16, /* data type is uint16. */ + RKNN_TENSOR_INT32, /* data type is int32. */ + RKNN_TENSOR_UINT32, /* data type is uint32. */ + RKNN_TENSOR_INT64, /* data type is int64. */ + RKNN_TENSOR_BOOL, + RKNN_TENSOR_INT4, + RKNN_TENSOR_BFLOAT16, + + RKNN_TENSOR_TYPE_MAX +} rknn_tensor_type; + +inline static const char* get_type_string(rknn_tensor_type type) +{ + switch(type) { + case RKNN_TENSOR_FLOAT32: return "FP32"; + case RKNN_TENSOR_FLOAT16: return "FP16"; + case RKNN_TENSOR_INT8: return "INT8"; + case RKNN_TENSOR_UINT8: return "UINT8"; + case RKNN_TENSOR_INT16: return "INT16"; + case RKNN_TENSOR_UINT16: return "UINT16"; + case RKNN_TENSOR_INT32: return "INT32"; + case RKNN_TENSOR_UINT32: return "UINT32"; + case RKNN_TENSOR_INT64: return "INT64"; + case RKNN_TENSOR_BOOL: return "BOOL"; + case RKNN_TENSOR_INT4: return "INT4"; + case RKNN_TENSOR_BFLOAT16: return "BF16"; + default: return "UNKNOW"; + } +} + +/* + the quantitative type. +*/ +typedef enum _rknn_tensor_qnt_type { + RKNN_TENSOR_QNT_NONE = 0, /* none. */ + RKNN_TENSOR_QNT_DFP, /* dynamic fixed point. */ + RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC, /* asymmetric affine. */ + + RKNN_TENSOR_QNT_MAX +} rknn_tensor_qnt_type; + +inline static const char* get_qnt_type_string(rknn_tensor_qnt_type type) +{ + switch(type) { + case RKNN_TENSOR_QNT_NONE: return "NONE"; + case RKNN_TENSOR_QNT_DFP: return "DFP"; + case RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC: return "AFFINE"; + default: return "UNKNOW"; + } +} + +/* + the tensor data format. +*/ +typedef enum _rknn_tensor_format { + RKNN_TENSOR_NCHW = 0, /* data format is NCHW. */ + RKNN_TENSOR_NHWC, /* data format is NHWC. */ + RKNN_TENSOR_NC1HWC2, /* data format is NC1HWC2. */ + RKNN_TENSOR_UNDEFINED, + + RKNN_TENSOR_FORMAT_MAX +} rknn_tensor_format; + +/* + the mode of running on target NPU core. +*/ +typedef enum _rknn_core_mask { + RKNN_NPU_CORE_AUTO = 0, /* default, run on NPU core randomly. */ + RKNN_NPU_CORE_0 = 1, /* run on NPU core 0. */ + RKNN_NPU_CORE_1 = 2, /* run on NPU core 1. */ + RKNN_NPU_CORE_2 = 4, /* run on NPU core 2. */ + RKNN_NPU_CORE_0_1 = RKNN_NPU_CORE_0 | RKNN_NPU_CORE_1, /* run on NPU core 0 and core 1. */ + RKNN_NPU_CORE_0_1_2 = RKNN_NPU_CORE_0_1 | RKNN_NPU_CORE_2, /* run on NPU core 0 and core 1 and core 2. */ + RKNN_NPU_CORE_ALL = 0xffff, /* auto choice, run on NPU cores depending on platform */ + + RKNN_NPU_CORE_UNDEFINED, +} rknn_core_mask; + +inline static const char* get_format_string(rknn_tensor_format fmt) +{ + switch(fmt) { + case RKNN_TENSOR_NCHW: return "NCHW"; + case RKNN_TENSOR_NHWC: return "NHWC"; + case RKNN_TENSOR_NC1HWC2: return "NC1HWC2"; + case RKNN_TENSOR_UNDEFINED: return "UNDEFINED"; + default: return "UNKNOW"; + } +} + +/* + the information for RKNN_QUERY_IN_OUT_NUM. +*/ +typedef struct _rknn_input_output_num { + uint32_t n_input; /* the number of input. */ + uint32_t n_output; /* the number of output. */ +} rknn_input_output_num; + +/* + the information for RKNN_QUERY_INPUT_ATTR / RKNN_QUERY_OUTPUT_ATTR. +*/ +typedef struct _rknn_tensor_attr { + uint32_t index; /* input parameter, the index of input/output tensor, + need set before call rknn_query. */ + + uint32_t n_dims; /* the number of dimensions. */ + uint32_t dims[RKNN_MAX_DIMS]; /* the dimensions array. */ + char name[RKNN_MAX_NAME_LEN]; /* the name of tensor. */ + + uint32_t n_elems; /* the number of elements. */ + uint32_t size; /* the bytes size of tensor. */ + + rknn_tensor_format fmt; /* the data format of tensor. */ + rknn_tensor_type type; /* the data type of tensor. */ + rknn_tensor_qnt_type qnt_type; /* the quantitative type of tensor. */ + int8_t fl; /* fractional length for RKNN_TENSOR_QNT_DFP. */ + int32_t zp; /* zero point for RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC. */ + float scale; /* scale for RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC. */ + + uint32_t w_stride; /* the stride of tensor along the width dimention of input, + Note: it is read-only, 0 means equal to width. */ + uint32_t size_with_stride; /* the bytes size of tensor with stride. */ + + uint8_t pass_through; /* pass through mode, for rknn_set_io_mem interface. + if TRUE, the buf data is passed directly to the input node of the rknn model + without any conversion. the following variables do not need to be set. + if FALSE, the buf data is converted into an input consistent with the model + according to the following type and fmt. so the following variables + need to be set.*/ + uint32_t h_stride; /* the stride along the height dimention of input, + Note: it is write-only, if it was set to 0, h_stride = height. */ +} rknn_tensor_attr; + +typedef struct _rknn_input_range { + uint32_t index; /* input parameter, the index of input/output tensor, + need set before call rknn_query. */ + uint32_t shape_number; /* the number of shape. */ + rknn_tensor_format fmt; /* the data format of tensor. */ + char name[RKNN_MAX_NAME_LEN]; /* the name of tensor. */ + uint32_t dyn_range[RKNN_MAX_DYNAMIC_SHAPE_NUM][RKNN_MAX_DIMS]; /* the dynamic input dimensions range. */ + uint32_t n_dims; /* the number of dimensions. */ + +} rknn_input_range; + +/* + the information for RKNN_QUERY_PERF_DETAIL. +*/ +typedef struct _rknn_perf_detail { + char* perf_data; /* the string pointer of perf detail. don't need free it by user. */ + uint64_t data_len; /* the string length. */ +} rknn_perf_detail; + +/* + the information for RKNN_QUERY_PERF_RUN. +*/ +typedef struct _rknn_perf_run { + int64_t run_duration; /* real inference time (us) */ +} rknn_perf_run; + +/* + the information for RKNN_QUERY_SDK_VERSION. +*/ +typedef struct _rknn_sdk_version { + char api_version[256]; /* the version of rknn api. */ + char drv_version[256]; /* the version of rknn driver. */ +} rknn_sdk_version; + +/* + the information for RKNN_QUERY_MEM_SIZE. +*/ +typedef struct _rknn_mem_size { + uint32_t total_weight_size; /* the weight memory size */ + uint32_t total_internal_size; /* the internal memory size, exclude inputs/outputs */ + uint64_t total_dma_allocated_size; /* total dma memory allocated size */ + uint32_t total_sram_size; /* total system sram size reserved for rknn */ + uint32_t free_sram_size; /* free system sram size reserved for rknn */ + uint32_t reserved[10]; /* reserved */ +} rknn_mem_size; + +/* + the information for RKNN_QUERY_CUSTOM_STRING. +*/ +typedef struct _rknn_custom_string { + char string[1024]; /* the string of custom, lengths max to 1024 bytes */ +} rknn_custom_string; + +/* + The flags of rknn_tensor_mem. +*/ +typedef enum _rknn_tensor_mem_flags { + RKNN_TENSOR_MEMORY_FLAGS_ALLOC_INSIDE = 1, /*Used to mark in rknn_destroy_mem() whether it is necessary to release the "mem" pointer itself. + If the flag RKNN_TENSOR_MEMORY_FLAGS_ALLOC_INSIDE is set, rknn_destroy_mem() will call free(mem).*/ + RKNN_TENSOR_MEMORY_FLAGS_FROM_FD = 2, /*Used to mark in rknn_create_mem_from_fd() whether it is necessary to release the "mem" pointer itself. + If the flag RKNN_TENSOR_MEMORY_FLAGS_FROM_FD is set, rknn_destroy_mem() will call free(mem).*/ + RKNN_TENSOR_MEMORY_FLAGS_FROM_PHYS = 3, /*Used to mark in rknn_create_mem_from_phys() whether it is necessary to release the "mem" pointer itself. + If the flag RKNN_TENSOR_MEMORY_FLAGS_FROM_PHYS is set, rknn_destroy_mem() will call free(mem).*/ + RKNN_TENSOR_MEMORY_FLAGS_UNKNOWN +} rknn_tensor_mem_flags; + +/* + The mode to allocate rknn memory. +*/ +typedef enum _rknn_mem_alloc_flags { + RKNN_FLAG_MEMORY_FLAGS_DEFAULT = 0 << 0, /* Same with RKNN_FLAG_MEMORY_CACHEABLE */ + RKNN_FLAG_MEMORY_CACHEABLE = 1 << 0, /* Create Cacheable memory. */ + RKNN_FLAG_MEMORY_NON_CACHEABLE = 1 << 1, /* Create NON-Cacheable memory. */ + RKNN_FLAG_MEMORY_TRY_ALLOC_SRAM = 1 << 2, /* Try to allocate memory in SRAM if possible. if SRAM is not enough, allocate rest memory in DRAM. */ +} rknn_mem_alloc_flags; + +/* + The mode to sync cacheable rknn memory. +*/ +typedef enum _rknn_mem_sync_mode { + RKNN_MEMORY_SYNC_TO_DEVICE = 0x1, /* the mode used for consistency of device access after CPU accesses data. */ + RKNN_MEMORY_SYNC_FROM_DEVICE = 0x2, /* the mode used for consistency of CPU access after device accesses data. */ + RKNN_MEMORY_SYNC_BIDIRECTIONAL = RKNN_MEMORY_SYNC_TO_DEVICE | RKNN_MEMORY_SYNC_FROM_DEVICE, /* the mode used for consistency of data access + between device and CPU in both directions. */ +} rknn_mem_sync_mode; + +/* + the memory information of tensor. +*/ +typedef struct _rknn_tensor_memory { + void* virt_addr; /* the virtual address of tensor buffer. */ + uint64_t phys_addr; /* the physical address of tensor buffer. */ + int32_t fd; /* the fd of tensor buffer. */ + int32_t offset; /* indicates the offset of the memory. */ + uint32_t size; /* the size of tensor buffer. */ + uint32_t flags; /* the flags of tensor buffer, reserved */ + void * priv_data; /* the private data of tensor buffer. */ +} rknn_tensor_mem; + +/* + the input information for rknn_input_set. +*/ +typedef struct _rknn_input { + uint32_t index; /* the input index. */ + void* buf; /* the input buf for index. */ + uint32_t size; /* the size of input buf. */ + uint8_t pass_through; /* pass through mode. + if TRUE, the buf data is passed directly to the input node of the rknn model + without any conversion. the following variables do not need to be set. + if FALSE, the buf data is converted into an input consistent with the model + according to the following type and fmt. so the following variables + need to be set.*/ + rknn_tensor_type type; /* the data type of input buf. */ + rknn_tensor_format fmt; /* the data format of input buf. + currently the internal input format of NPU is NCHW by default. + so entering NCHW data can avoid the format conversion in the driver. */ +} rknn_input; + +/* + the output information for rknn_outputs_get. +*/ +typedef struct _rknn_output { + uint8_t want_float; /* want transfer output data to float */ + uint8_t is_prealloc; /* whether buf is pre-allocated. + if TRUE, the following variables need to be set. + if FALSE, the following variables do not need to be set. */ + uint32_t index; /* the output index. */ + void* buf; /* the output buf for index. + when is_prealloc = FALSE and rknn_outputs_release called, + this buf pointer will be free and don't use it anymore. */ + uint32_t size; /* the size of output buf. */ +} rknn_output; + +/* + the extend information for rknn_init. +*/ +typedef struct _rknn_init_extend { + rknn_context ctx; /* rknn context */ + int32_t real_model_offset; /* real rknn model file offset, only valid when init context with rknn file path and zero-copy model model */ + uint32_t real_model_size; /* real rknn model file size, only valid when init context with rknn file path and zero-copy model model */ + int32_t model_buffer_fd; /* the fd of model buffer. */ + uint32_t model_buffer_flags; /* the flags of model_buffer */ + uint8_t reserved[112]; /* reserved */ +} rknn_init_extend; + +/* + the extend information for rknn_run. +*/ +typedef struct _rknn_run_extend { + uint64_t frame_id; /* output parameter, indicate current frame id of run. */ + int32_t non_block; /* block flag of run, 0 is block else 1 is non block */ + int32_t timeout_ms; /* timeout for block mode, in milliseconds */ + int32_t fence_fd; /* fence fd from other unit */ +} rknn_run_extend; + +/* + the extend information for rknn_outputs_get. +*/ +typedef struct _rknn_output_extend { + uint64_t frame_id; /* output parameter, indicate the frame id of outputs, corresponds to + struct rknn_run_extend.frame_id.*/ +} rknn_output_extend; + + +/* rknn_init + + initial the context and load the rknn model. + + input: + rknn_context* context the pointer of context handle. + void* model if size > 0, pointer to the rknn model, if size = 0, filepath to the rknn model. + uint32_t size the size of rknn model. + uint32_t flag extend flag, see the define of RKNN_FLAG_XXX_XXX. + rknn_init_extend* extend the extend information of init. + return: + int error code. +*/ +int rknn_init(rknn_context* context, void* model, uint32_t size, uint32_t flag, rknn_init_extend* extend); + +/* rknn_dup_context + + initial the context and load the rknn model. + + input: + rknn_context* context_in the pointer of context in handle. + rknn_context* context_out the pointer of context out handle. + return: + int error code. +*/ +int rknn_dup_context(rknn_context* context_in, rknn_context* context_out); + +/* rknn_destroy + + unload the rknn model and destroy the context. + + input: + rknn_context context the handle of context. + return: + int error code. +*/ +int rknn_destroy(rknn_context context); + + +/* rknn_query + + query the information about model or others. see rknn_query_cmd. + + input: + rknn_context context the handle of context. + rknn_query_cmd cmd the command of query. + void* info the buffer point of information. + uint32_t size the size of information. + return: + int error code. +*/ +int rknn_query(rknn_context context, rknn_query_cmd cmd, void* info, uint32_t size); + + +/* rknn_inputs_set + + set inputs information by input index of rknn model. + inputs information see rknn_input. + + input: + rknn_context context the handle of context. + uint32_t n_inputs the number of inputs. + rknn_input inputs[] the arrays of inputs information, see rknn_input. + return: + int error code +*/ +int rknn_inputs_set(rknn_context context, uint32_t n_inputs, rknn_input inputs[]); + +/* + rknn_set_batch_core_num + + set rknn batch core_num. + + input: + rknn_context context the handle of context. + int core_num the core number. + return: + int error code. + +*/ +int rknn_set_batch_core_num(rknn_context context, int core_num); + +/* rknn_set_core_mask + + set the core mask for the model.(only supported on multi-core NPU platform) + + RKNN_NPU_CORE_AUTO: auto mode, default value + RKNN_NPU_CORE_0: core 0 mode + RKNN_NPU_CORE_1: core 1 mode + RKNN_NPU_CORE_2: core 2 mode + RKNN_NPU_CORE_0_1: combine core 0/1 mode + RKNN_NPU_CORE_0_1_2: combine core 0/1/2 mode + RKNN_NPU_CORE_ALL: auto mode, select multiple npu cores to run depending on platform + + + input: + rknn_context context the handle of context. + rknn_core_mask core_mask the core mask. + return: + int error code. +*/ +int rknn_set_core_mask(rknn_context context, rknn_core_mask core_mask); + +/* rknn_run + + run the model to execute inference. + + input: + rknn_context context the handle of context. + rknn_run_extend* extend the extend information of run. + return: + int error code. +*/ +int rknn_run(rknn_context context, rknn_run_extend* extend); + + +/* rknn_wait + + wait the model after execute inference. + + input: + rknn_context context the handle of context. + rknn_run_extend* extend the extend information of run. + return: + int error code. +*/ +int rknn_wait(rknn_context context, rknn_run_extend* extend); + + +/* rknn_outputs_get + + wait the inference to finish and get the outputs. + this function will block until inference finish. + the results will set to outputs[]. + + input: + rknn_context context the handle of context. + uint32_t n_outputs the number of outputs. + rknn_output outputs[] the arrays of output, see rknn_output. + rknn_output_extend* the extend information of output. + return: + int error code. +*/ +int rknn_outputs_get(rknn_context context, uint32_t n_outputs, rknn_output outputs[], rknn_output_extend* extend); + + +/* rknn_outputs_release + + release the outputs that get by rknn_outputs_get. + after called, the rknn_output[x].buf get from rknn_outputs_get will + also be free when rknn_output[x].is_prealloc = FALSE. + + input: + rknn_context context the handle of context. + uint32_t n_ouputs the number of outputs. + rknn_output outputs[] the arrays of output. + return: + int error code +*/ +int rknn_outputs_release(rknn_context context, uint32_t n_ouputs, rknn_output outputs[]); + + +/* new api for zero copy */ + +/* rknn_create_mem_from_phys (memory allocated outside) + + initialize tensor memory from physical address. + + input: + rknn_context ctx the handle of context. + uint64_t phys_addr physical address. + void *virt_addr virtual address. + uint32_t size the size of tensor buffer. + return: + rknn_tensor_mem the pointer of tensor memory information. +*/ +rknn_tensor_mem* rknn_create_mem_from_phys(rknn_context ctx, uint64_t phys_addr, void *virt_addr, uint32_t size); + + +/* rknn_create_mem_from_fd (memory allocated outside) + + initialize tensor memory from file description. + + input: + rknn_context ctx the handle of context. + int32_t fd file description. + void *virt_addr virtual address. + uint32_t size the size of tensor buffer. + int32_t offset indicates the offset of the memory (virt_addr without offset). + return: + rknn_tensor_mem the pointer of tensor memory information. +*/ +rknn_tensor_mem* rknn_create_mem_from_fd(rknn_context ctx, int32_t fd, void *virt_addr, uint32_t size, int32_t offset); + + +/* rknn_create_mem_from_mb_blk (memory allocated outside) + + create tensor memory from mb_blk. + + input: + rknn_context ctx the handle of context. + void *mb_blk mb_blk allocate from system api. + int32_t offset indicates the offset of the memory. + return: + rknn_tensor_mem the pointer of tensor memory information. +*/ +rknn_tensor_mem* rknn_create_mem_from_mb_blk(rknn_context ctx, void *mb_blk, int32_t offset); + + +/* rknn_create_mem (memory allocated inside) + + create tensor memory. + + input: + rknn_context ctx the handle of context. + uint32_t size the size of tensor buffer. + return: + rknn_tensor_mem the pointer of tensor memory information. +*/ +rknn_tensor_mem* rknn_create_mem(rknn_context ctx, uint32_t size); + +/* rknn_create_mem2 (memory allocated inside) + + create tensor memory. + + input: + rknn_context ctx the handle of context. + uint64_t size the size of tensor buffer. + uint64_t alloc_flags memory allocation flags. + return: + rknn_tensor_mem the pointer of tensor memory information. +*/ +rknn_tensor_mem* rknn_create_mem2(rknn_context ctx, uint64_t size, uint64_t alloc_flags); + +/* rknn_destroy_mem (support allocate inside and outside) + + destroy tensor memory. + + input: + rknn_context ctx the handle of context. + rknn_tensor_mem *mem the pointer of tensor memory information. + return: + int error code +*/ +int rknn_destroy_mem(rknn_context ctx, rknn_tensor_mem *mem); + + +/* rknn_set_weight_mem + + set the weight memory. + + input: + rknn_context ctx the handle of context. + rknn_tensor_mem *mem the array of tensor memory information + return: + int error code. +*/ +int rknn_set_weight_mem(rknn_context ctx, rknn_tensor_mem *mem); + + +/* rknn_set_internal_mem + + set the internal memory. + + input: + rknn_context ctx the handle of context. + rknn_tensor_mem *mem the array of tensor memory information + return: + int error code. +*/ +int rknn_set_internal_mem(rknn_context ctx, rknn_tensor_mem *mem); + + +/* rknn_set_io_mem + + set the input and output tensors buffer. + + input: + rknn_context ctx the handle of context. + rknn_tensor_mem *mem the array of tensor memory information. + rknn_tensor_attr *attr the attribute of input or output tensor buffer. + return: + int error code. +*/ +int rknn_set_io_mem(rknn_context ctx, rknn_tensor_mem *mem, rknn_tensor_attr *attr); + +/* rknn_set_input_shape(deprecated) + + set the input tensor shape (only valid for dynamic shape rknn model). + + input: + rknn_context ctx the handle of context. + rknn_tensor_attr *attr the attribute of input or output tensor buffer. + return: + int error code. +*/ +int rknn_set_input_shape(rknn_context ctx, rknn_tensor_attr* attr); + +/* rknn_set_input_shapes + + set all the input tensor shapes. graph will run under current set of input shapes after rknn_set_input_shapes.(only valid for dynamic shape rknn model). + + input: + rknn_context ctx the handle of context. + uint32_t n_inputs the number of inputs. + rknn_tensor_attr attr[] the attribute array of all input tensors. + return: + int error code. +*/ +int rknn_set_input_shapes(rknn_context ctx, uint32_t n_inputs, rknn_tensor_attr attr[]); + +/* rknn_mem_sync + + sync cacheable rknn memory when both cpu and device access data. + + input: + rknn_context context the handle of context. + rknn_tensor_mem *mem the pointer of tensor memory information. + rknn_mem_sync_mode mode the mode of sync cache. + return: + int error code. +*/ +int rknn_mem_sync(rknn_context context, rknn_tensor_mem* mem, rknn_mem_sync_mode mode); + +#ifdef __cplusplus +} //extern "C" +#endif + +#endif //_RKNN_API_H diff --git a/docs/face-scan-source/include/stb_image.h b/docs/face-scan-source/include/stb_image.h new file mode 100644 index 0000000..accef48 --- /dev/null +++ b/docs/face-scan-source/include/stb_image.h @@ -0,0 +1,7762 @@ +/* stb_image - v2.26 - public domain image loader - http://nothings.org/stb + no warranty implied; use at your own risk + + Do this: + #define STB_IMAGE_IMPLEMENTATION + before you include this file in *one* C or C++ file to create the implementation. + + // i.e. it should look like this: + #include ... + #include ... + #include ... + #define STB_IMAGE_IMPLEMENTATION + #include "stb_image.h" + + You can #define STBI_ASSERT(x) before the #include to avoid using assert.h. + And #define STBI_MALLOC, STBI_REALLOC, and STBI_FREE to avoid using malloc,realloc,free + + + QUICK NOTES: + Primarily of interest to game developers and other people who can + avoid problematic images and only need the trivial interface + + JPEG baseline & progressive (12 bpc/arithmetic not supported, same as stock IJG lib) + PNG 1/2/4/8/16-bit-per-channel + + TGA (not sure what subset, if a subset) + BMP non-1bpp, non-RLE + PSD (composited view only, no extra channels, 8/16 bit-per-channel) + + GIF (*comp always reports as 4-channel) + HDR (radiance rgbE format) + PIC (Softimage PIC) + PNM (PPM and PGM binary only) + + Animated GIF still needs a proper API, but here's one way to do it: + http://gist.github.com/urraka/685d9a6340b26b830d49 + + - decode from memory or through FILE (define STBI_NO_STDIO to remove code) + - decode from arbitrary I/O callbacks + - SIMD acceleration on x86/x64 (SSE2) and ARM (NEON) + + Full documentation under "DOCUMENTATION" below. + + +LICENSE + + See end of file for license information. + +RECENT REVISION HISTORY: + + 2.26 (2020-07-13) many minor fixes + 2.25 (2020-02-02) fix warnings + 2.24 (2020-02-02) fix warnings; thread-local failure_reason and flip_vertically + 2.23 (2019-08-11) fix clang static analysis warning + 2.22 (2019-03-04) gif fixes, fix warnings + 2.21 (2019-02-25) fix typo in comment + 2.20 (2019-02-07) support utf8 filenames in Windows; fix warnings and platform ifdefs + 2.19 (2018-02-11) fix warning + 2.18 (2018-01-30) fix warnings + 2.17 (2018-01-29) bugfix, 1-bit BMP, 16-bitness query, fix warnings + 2.16 (2017-07-23) all functions have 16-bit variants; optimizations; bugfixes + 2.15 (2017-03-18) fix png-1,2,4; all Imagenet JPGs; no runtime SSE detection on GCC + 2.14 (2017-03-03) remove deprecated STBI_JPEG_OLD; fixes for Imagenet JPGs + 2.13 (2016-12-04) experimental 16-bit API, only for PNG so far; fixes + 2.12 (2016-04-02) fix typo in 2.11 PSD fix that caused crashes + 2.11 (2016-04-02) 16-bit PNGS; enable SSE2 in non-gcc x64 + RGB-format JPEG; remove white matting in PSD; + allocate large structures on the stack; + correct channel count for PNG & BMP + 2.10 (2016-01-22) avoid warning introduced in 2.09 + 2.09 (2016-01-16) 16-bit TGA; comments in PNM files; STBI_REALLOC_SIZED + + See end of file for full revision history. + + + ============================ Contributors ========================= + + Image formats Extensions, features + Sean Barrett (jpeg, png, bmp) Jetro Lauha (stbi_info) + Nicolas Schulz (hdr, psd) Martin "SpartanJ" Golini (stbi_info) + Jonathan Dummer (tga) James "moose2000" Brown (iPhone PNG) + Jean-Marc Lienher (gif) Ben "Disch" Wenger (io callbacks) + Tom Seddon (pic) Omar Cornut (1/2/4-bit PNG) + Thatcher Ulrich (psd) Nicolas Guillemot (vertical flip) + Ken Miller (pgm, ppm) Richard Mitton (16-bit PSD) + github:urraka (animated gif) Junggon Kim (PNM comments) + Christopher Forseth (animated gif) Daniel Gibson (16-bit TGA) + socks-the-fox (16-bit PNG) + Jeremy Sawicki (handle all ImageNet JPGs) + Optimizations & bugfixes Mikhail Morozov (1-bit BMP) + Fabian "ryg" Giesen Anael Seghezzi (is-16-bit query) + Arseny Kapoulkine + John-Mark Allen + Carmelo J Fdez-Aguera + + Bug & warning fixes + Marc LeBlanc David Woo Guillaume George Martins Mozeiko + Christpher Lloyd Jerry Jansson Joseph Thomson Blazej Dariusz Roszkowski + Phil Jordan Dave Moore Roy Eltham + Hayaki Saito Nathan Reed Won Chun + Luke Graham Johan Duparc Nick Verigakis the Horde3D community + Thomas Ruf Ronny Chevalier github:rlyeh + Janez Zemva John Bartholomew Michal Cichon github:romigrou + Jonathan Blow Ken Hamada Tero Hanninen github:svdijk + Laurent Gomila Cort Stratton github:snagar + Aruelien Pocheville Sergio Gonzalez Thibault Reuille github:Zelex + Cass Everitt Ryamond Barbiero github:grim210 + Paul Du Bois Engin Manap Aldo Culquicondor github:sammyhw + Philipp Wiesemann Dale Weiler Oriol Ferrer Mesia github:phprus + Josh Tobin Matthew Gregan github:poppolopoppo + Julian Raschke Gregory Mullen Christian Floisand github:darealshinji + Baldur Karlsson Kevin Schmidt JR Smith github:Michaelangel007 + Brad Weinberger Matvey Cherevko [reserved] + Luca Sas Alexander Veselov Zack Middleton [reserved] + Ryan C. Gordon [reserved] [reserved] + DO NOT ADD YOUR NAME HERE + + To add your name to the credits, pick a random blank space in the middle and fill it. + 80% of merge conflicts on stb PRs are due to people adding their name at the end + of the credits. +*/ + +#ifndef STBI_INCLUDE_STB_IMAGE_H +#define STBI_INCLUDE_STB_IMAGE_H + +// DOCUMENTATION +// +// Limitations: +// - no 12-bit-per-channel JPEG +// - no JPEGs with arithmetic coding +// - GIF always returns *comp=4 +// +// Basic usage (see HDR discussion below for HDR usage): +// int x,y,n; +// unsigned char *data = stbi_load(filename, &x, &y, &n, 0); +// // ... process data if not NULL ... +// // ... x = width, y = height, n = # 8-bit components per pixel ... +// // ... replace '0' with '1'..'4' to force that many components per pixel +// // ... but 'n' will always be the number that it would have been if you said 0 +// stbi_image_free(data) +// +// Standard parameters: +// int *x -- outputs image width in pixels +// int *y -- outputs image height in pixels +// int *channels_in_file -- outputs # of image components in image file +// int desired_channels -- if non-zero, # of image components requested in result +// +// The return value from an image loader is an 'unsigned char *' which points +// to the pixel data, or NULL on an allocation failure or if the image is +// corrupt or invalid. The pixel data consists of *y scanlines of *x pixels, +// with each pixel consisting of N interleaved 8-bit components; the first +// pixel pointed to is top-left-most in the image. There is no padding between +// image scanlines or between pixels, regardless of format. The number of +// components N is 'desired_channels' if desired_channels is non-zero, or +// *channels_in_file otherwise. If desired_channels is non-zero, +// *channels_in_file has the number of components that _would_ have been +// output otherwise. E.g. if you set desired_channels to 4, you will always +// get RGBA output, but you can check *channels_in_file to see if it's trivially +// opaque because e.g. there were only 3 channels in the source image. +// +// An output image with N components has the following components interleaved +// in this order in each pixel: +// +// N=#comp components +// 1 grey +// 2 grey, alpha +// 3 red, green, blue +// 4 red, green, blue, alpha +// +// If image loading fails for any reason, the return value will be NULL, +// and *x, *y, *channels_in_file will be unchanged. The function +// stbi_failure_reason() can be queried for an extremely brief, end-user +// unfriendly explanation of why the load failed. Define STBI_NO_FAILURE_STRINGS +// to avoid compiling these strings at all, and STBI_FAILURE_USERMSG to get slightly +// more user-friendly ones. +// +// Paletted PNG, BMP, GIF, and PIC images are automatically depalettized. +// +// =========================================================================== +// +// UNICODE: +// +// If compiling for Windows and you wish to use Unicode filenames, compile +// with +// #define STBI_WINDOWS_UTF8 +// and pass utf8-encoded filenames. Call stbi_convert_wchar_to_utf8 to convert +// Windows wchar_t filenames to utf8. +// +// =========================================================================== +// +// Philosophy +// +// stb libraries are designed with the following priorities: +// +// 1. easy to use +// 2. easy to maintain +// 3. good performance +// +// Sometimes I let "good performance" creep up in priority over "easy to maintain", +// and for best performance I may provide less-easy-to-use APIs that give higher +// performance, in addition to the easy-to-use ones. Nevertheless, it's important +// to keep in mind that from the standpoint of you, a client of this library, +// all you care about is #1 and #3, and stb libraries DO NOT emphasize #3 above all. +// +// Some secondary priorities arise directly from the first two, some of which +// provide more explicit reasons why performance can't be emphasized. +// +// - Portable ("ease of use") +// - Small source code footprint ("easy to maintain") +// - No dependencies ("ease of use") +// +// =========================================================================== +// +// I/O callbacks +// +// I/O callbacks allow you to read from arbitrary sources, like packaged +// files or some other source. Data read from callbacks are processed +// through a small internal buffer (currently 128 bytes) to try to reduce +// overhead. +// +// The three functions you must define are "read" (reads some bytes of data), +// "skip" (skips some bytes of data), "eof" (reports if the stream is at the end). +// +// =========================================================================== +// +// SIMD support +// +// The JPEG decoder will try to automatically use SIMD kernels on x86 when +// supported by the compiler. For ARM Neon support, you must explicitly +// request it. +// +// (The old do-it-yourself SIMD API is no longer supported in the current +// code.) +// +// On x86, SSE2 will automatically be used when available based on a run-time +// test; if not, the generic C versions are used as a fall-back. On ARM targets, +// the typical path is to have separate builds for NEON and non-NEON devices +// (at least this is true for iOS and Android). Therefore, the NEON support is +// toggled by a build flag: define STBI_NEON to get NEON loops. +// +// If for some reason you do not want to use any of SIMD code, or if +// you have issues compiling it, you can disable it entirely by +// defining STBI_NO_SIMD. +// +// =========================================================================== +// +// HDR image support (disable by defining STBI_NO_HDR) +// +// stb_image supports loading HDR images in general, and currently the Radiance +// .HDR file format specifically. You can still load any file through the existing +// interface; if you attempt to load an HDR file, it will be automatically remapped +// to LDR, assuming gamma 2.2 and an arbitrary scale factor defaulting to 1; +// both of these constants can be reconfigured through this interface: +// +// stbi_hdr_to_ldr_gamma(2.2f); +// stbi_hdr_to_ldr_scale(1.0f); +// +// (note, do not use _inverse_ constants; stbi_image will invert them +// appropriately). +// +// Additionally, there is a new, parallel interface for loading files as +// (linear) floats to preserve the full dynamic range: +// +// float *data = stbi_loadf(filename, &x, &y, &n, 0); +// +// If you load LDR images through this interface, those images will +// be promoted to floating point values, run through the inverse of +// constants corresponding to the above: +// +// stbi_ldr_to_hdr_scale(1.0f); +// stbi_ldr_to_hdr_gamma(2.2f); +// +// Finally, given a filename (or an open file or memory block--see header +// file for details) containing image data, you can query for the "most +// appropriate" interface to use (that is, whether the image is HDR or +// not), using: +// +// stbi_is_hdr(char *filename); +// +// =========================================================================== +// +// iPhone PNG support: +// +// By default we convert iphone-formatted PNGs back to RGB, even though +// they are internally encoded differently. You can disable this conversion +// by calling stbi_convert_iphone_png_to_rgb(0), in which case +// you will always just get the native iphone "format" through (which +// is BGR stored in RGB). +// +// Call stbi_set_unpremultiply_on_load(1) as well to force a divide per +// pixel to remove any premultiplied alpha *only* if the image file explicitly +// says there's premultiplied data (currently only happens in iPhone images, +// and only if iPhone convert-to-rgb processing is on). +// +// =========================================================================== +// +// ADDITIONAL CONFIGURATION +// +// - You can suppress implementation of any of the decoders to reduce +// your code footprint by #defining one or more of the following +// symbols before creating the implementation. +// +// STBI_NO_JPEG +// STBI_NO_PNG +// STBI_NO_BMP +// STBI_NO_PSD +// STBI_NO_TGA +// STBI_NO_GIF +// STBI_NO_HDR +// STBI_NO_PIC +// STBI_NO_PNM (.ppm and .pgm) +// +// - You can request *only* certain decoders and suppress all other ones +// (this will be more forward-compatible, as addition of new decoders +// doesn't require you to disable them explicitly): +// +// STBI_ONLY_JPEG +// STBI_ONLY_PNG +// STBI_ONLY_BMP +// STBI_ONLY_PSD +// STBI_ONLY_TGA +// STBI_ONLY_GIF +// STBI_ONLY_HDR +// STBI_ONLY_PIC +// STBI_ONLY_PNM (.ppm and .pgm) +// +// - If you use STBI_NO_PNG (or _ONLY_ without PNG), and you still +// want the zlib decoder to be available, #define STBI_SUPPORT_ZLIB +// +// - If you define STBI_MAX_DIMENSIONS, stb_image will reject images greater +// than that size (in either width or height) without further processing. +// This is to let programs in the wild set an upper bound to prevent +// denial-of-service attacks on untrusted data, as one could generate a +// valid image of gigantic dimensions and force stb_image to allocate a +// huge block of memory and spend disproportionate time decoding it. By +// default this is set to (1 << 24), which is 16777216, but that's still +// very big. + +#ifndef STBI_NO_STDIO +#include +#endif // STBI_NO_STDIO + +#define STBI_VERSION 1 + +enum +{ + STBI_default = 0, // only used for desired_channels + + STBI_grey = 1, + STBI_grey_alpha = 2, + STBI_rgb = 3, + STBI_rgb_alpha = 4 +}; + +#include +typedef unsigned char stbi_uc; +typedef unsigned short stbi_us; + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef STBIDEF +#ifdef STB_IMAGE_STATIC +#define STBIDEF static +#else +#define STBIDEF extern +#endif +#endif + +////////////////////////////////////////////////////////////////////////////// +// +// PRIMARY API - works on images of any type +// + +// +// load image by filename, open file, or memory buffer +// + +typedef struct +{ + int (*read) (void *user,char *data,int size); // fill 'data' with 'size' bytes. return number of bytes actually read + void (*skip) (void *user,int n); // skip the next 'n' bytes, or 'unget' the last -n bytes if negative + int (*eof) (void *user); // returns nonzero if we are at end of file/data +} stbi_io_callbacks; + +//////////////////////////////////// +// +// 8-bits-per-channel interface +// + +STBIDEF stbi_uc *stbi_load_from_memory (stbi_uc const *buffer, int len , int *x, int *y, int *channels_in_file, int desired_channels); +STBIDEF stbi_uc *stbi_load_from_callbacks(stbi_io_callbacks const *clbk , void *user, int *x, int *y, int *channels_in_file, int desired_channels); + +#ifndef STBI_NO_STDIO +STBIDEF stbi_uc *stbi_load (char const *filename, int *x, int *y, int *channels_in_file, int desired_channels); +STBIDEF stbi_uc *stbi_load_from_file (FILE *f, int *x, int *y, int *channels_in_file, int desired_channels); +// for stbi_load_from_file, file pointer is left pointing immediately after image +#endif + +#ifndef STBI_NO_GIF +STBIDEF stbi_uc *stbi_load_gif_from_memory(stbi_uc const *buffer, int len, int **delays, int *x, int *y, int *z, int *comp, int req_comp); +#endif + +#ifdef STBI_WINDOWS_UTF8 +STBIDEF int stbi_convert_wchar_to_utf8(char *buffer, size_t bufferlen, const wchar_t* input); +#endif + +//////////////////////////////////// +// +// 16-bits-per-channel interface +// + +STBIDEF stbi_us *stbi_load_16_from_memory (stbi_uc const *buffer, int len, int *x, int *y, int *channels_in_file, int desired_channels); +STBIDEF stbi_us *stbi_load_16_from_callbacks(stbi_io_callbacks const *clbk, void *user, int *x, int *y, int *channels_in_file, int desired_channels); + +#ifndef STBI_NO_STDIO +STBIDEF stbi_us *stbi_load_16 (char const *filename, int *x, int *y, int *channels_in_file, int desired_channels); +STBIDEF stbi_us *stbi_load_from_file_16(FILE *f, int *x, int *y, int *channels_in_file, int desired_channels); +#endif + +//////////////////////////////////// +// +// float-per-channel interface +// +#ifndef STBI_NO_LINEAR + STBIDEF float *stbi_loadf_from_memory (stbi_uc const *buffer, int len, int *x, int *y, int *channels_in_file, int desired_channels); + STBIDEF float *stbi_loadf_from_callbacks (stbi_io_callbacks const *clbk, void *user, int *x, int *y, int *channels_in_file, int desired_channels); + + #ifndef STBI_NO_STDIO + STBIDEF float *stbi_loadf (char const *filename, int *x, int *y, int *channels_in_file, int desired_channels); + STBIDEF float *stbi_loadf_from_file (FILE *f, int *x, int *y, int *channels_in_file, int desired_channels); + #endif +#endif + +#ifndef STBI_NO_HDR + STBIDEF void stbi_hdr_to_ldr_gamma(float gamma); + STBIDEF void stbi_hdr_to_ldr_scale(float scale); +#endif // STBI_NO_HDR + +#ifndef STBI_NO_LINEAR + STBIDEF void stbi_ldr_to_hdr_gamma(float gamma); + STBIDEF void stbi_ldr_to_hdr_scale(float scale); +#endif // STBI_NO_LINEAR + +// stbi_is_hdr is always defined, but always returns false if STBI_NO_HDR +STBIDEF int stbi_is_hdr_from_callbacks(stbi_io_callbacks const *clbk, void *user); +STBIDEF int stbi_is_hdr_from_memory(stbi_uc const *buffer, int len); +#ifndef STBI_NO_STDIO +STBIDEF int stbi_is_hdr (char const *filename); +STBIDEF int stbi_is_hdr_from_file(FILE *f); +#endif // STBI_NO_STDIO + + +// get a VERY brief reason for failure +// on most compilers (and ALL modern mainstream compilers) this is threadsafe +STBIDEF const char *stbi_failure_reason (void); + +// free the loaded image -- this is just free() +STBIDEF void stbi_image_free (void *retval_from_stbi_load); + +// get image dimensions & components without fully decoding +STBIDEF int stbi_info_from_memory(stbi_uc const *buffer, int len, int *x, int *y, int *comp); +STBIDEF int stbi_info_from_callbacks(stbi_io_callbacks const *clbk, void *user, int *x, int *y, int *comp); +STBIDEF int stbi_is_16_bit_from_memory(stbi_uc const *buffer, int len); +STBIDEF int stbi_is_16_bit_from_callbacks(stbi_io_callbacks const *clbk, void *user); + +#ifndef STBI_NO_STDIO +STBIDEF int stbi_info (char const *filename, int *x, int *y, int *comp); +STBIDEF int stbi_info_from_file (FILE *f, int *x, int *y, int *comp); +STBIDEF int stbi_is_16_bit (char const *filename); +STBIDEF int stbi_is_16_bit_from_file(FILE *f); +#endif + + + +// for image formats that explicitly notate that they have premultiplied alpha, +// we just return the colors as stored in the file. set this flag to force +// unpremultiplication. results are undefined if the unpremultiply overflow. +STBIDEF void stbi_set_unpremultiply_on_load(int flag_true_if_should_unpremultiply); + +// indicate whether we should process iphone images back to canonical format, +// or just pass them through "as-is" +STBIDEF void stbi_convert_iphone_png_to_rgb(int flag_true_if_should_convert); + +// flip the image vertically, so the first pixel in the output array is the bottom left +STBIDEF void stbi_set_flip_vertically_on_load(int flag_true_if_should_flip); + +// as above, but only applies to images loaded on the thread that calls the function +// this function is only available if your compiler supports thread-local variables; +// calling it will fail to link if your compiler doesn't +STBIDEF void stbi_set_flip_vertically_on_load_thread(int flag_true_if_should_flip); + +// ZLIB client - used by PNG, available for other purposes + +STBIDEF char *stbi_zlib_decode_malloc_guesssize(const char *buffer, int len, int initial_size, int *outlen); +STBIDEF char *stbi_zlib_decode_malloc_guesssize_headerflag(const char *buffer, int len, int initial_size, int *outlen, int parse_header); +STBIDEF char *stbi_zlib_decode_malloc(const char *buffer, int len, int *outlen); +STBIDEF int stbi_zlib_decode_buffer(char *obuffer, int olen, const char *ibuffer, int ilen); + +STBIDEF char *stbi_zlib_decode_noheader_malloc(const char *buffer, int len, int *outlen); +STBIDEF int stbi_zlib_decode_noheader_buffer(char *obuffer, int olen, const char *ibuffer, int ilen); + + +#ifdef __cplusplus +} +#endif + +// +// +//// end header file ///////////////////////////////////////////////////// +#endif // STBI_INCLUDE_STB_IMAGE_H + +#ifdef STB_IMAGE_IMPLEMENTATION + +#if defined(STBI_ONLY_JPEG) || defined(STBI_ONLY_PNG) || defined(STBI_ONLY_BMP) \ + || defined(STBI_ONLY_TGA) || defined(STBI_ONLY_GIF) || defined(STBI_ONLY_PSD) \ + || defined(STBI_ONLY_HDR) || defined(STBI_ONLY_PIC) || defined(STBI_ONLY_PNM) \ + || defined(STBI_ONLY_ZLIB) + #ifndef STBI_ONLY_JPEG + #define STBI_NO_JPEG + #endif + #ifndef STBI_ONLY_PNG + #define STBI_NO_PNG + #endif + #ifndef STBI_ONLY_BMP + #define STBI_NO_BMP + #endif + #ifndef STBI_ONLY_PSD + #define STBI_NO_PSD + #endif + #ifndef STBI_ONLY_TGA + #define STBI_NO_TGA + #endif + #ifndef STBI_ONLY_GIF + #define STBI_NO_GIF + #endif + #ifndef STBI_ONLY_HDR + #define STBI_NO_HDR + #endif + #ifndef STBI_ONLY_PIC + #define STBI_NO_PIC + #endif + #ifndef STBI_ONLY_PNM + #define STBI_NO_PNM + #endif +#endif + +#if defined(STBI_NO_PNG) && !defined(STBI_SUPPORT_ZLIB) && !defined(STBI_NO_ZLIB) +#define STBI_NO_ZLIB +#endif + + +#include +#include // ptrdiff_t on osx +#include +#include +#include + +#if !defined(STBI_NO_LINEAR) || !defined(STBI_NO_HDR) +#include // ldexp, pow +#endif + +#ifndef STBI_NO_STDIO +#include +#endif + +#ifndef STBI_ASSERT +#include +#define STBI_ASSERT(x) assert(x) +#endif + +#ifdef __cplusplus +#define STBI_EXTERN extern "C" +#else +#define STBI_EXTERN extern +#endif + + +#ifndef _MSC_VER + #ifdef __cplusplus + #define stbi_inline inline + #else + #define stbi_inline + #endif +#else + #define stbi_inline __forceinline +#endif + +#ifndef STBI_NO_THREAD_LOCALS + #if defined(__cplusplus) && __cplusplus >= 201103L + #define STBI_THREAD_LOCAL thread_local + #elif defined(__GNUC__) && __GNUC__ < 5 + #define STBI_THREAD_LOCAL __thread + #elif defined(_MSC_VER) + #define STBI_THREAD_LOCAL __declspec(thread) + #elif defined (__STDC_VERSION__) && __STDC_VERSION__ >= 201112L && !defined(__STDC_NO_THREADS__) + #define STBI_THREAD_LOCAL _Thread_local + #endif + + #ifndef STBI_THREAD_LOCAL + #if defined(__GNUC__) + #define STBI_THREAD_LOCAL __thread + #endif + #endif +#endif + +#ifdef _MSC_VER +typedef unsigned short stbi__uint16; +typedef signed short stbi__int16; +typedef unsigned int stbi__uint32; +typedef signed int stbi__int32; +#else +#include +typedef uint16_t stbi__uint16; +typedef int16_t stbi__int16; +typedef uint32_t stbi__uint32; +typedef int32_t stbi__int32; +#endif + +// should produce compiler error if size is wrong +typedef unsigned char validate_uint32[sizeof(stbi__uint32)==4 ? 1 : -1]; + +#ifdef _MSC_VER +#define STBI_NOTUSED(v) (void)(v) +#else +#define STBI_NOTUSED(v) (void)sizeof(v) +#endif + +#ifdef _MSC_VER +#define STBI_HAS_LROTL +#endif + +#ifdef STBI_HAS_LROTL + #define stbi_lrot(x,y) _lrotl(x,y) +#else + #define stbi_lrot(x,y) (((x) << (y)) | ((x) >> (32 - (y)))) +#endif + +#if defined(STBI_MALLOC) && defined(STBI_FREE) && (defined(STBI_REALLOC) || defined(STBI_REALLOC_SIZED)) +// ok +#elif !defined(STBI_MALLOC) && !defined(STBI_FREE) && !defined(STBI_REALLOC) && !defined(STBI_REALLOC_SIZED) +// ok +#else +#error "Must define all or none of STBI_MALLOC, STBI_FREE, and STBI_REALLOC (or STBI_REALLOC_SIZED)." +#endif + +#ifndef STBI_MALLOC +#define STBI_MALLOC(sz) malloc(sz) +#define STBI_REALLOC(p,newsz) realloc(p,newsz) +#define STBI_FREE(p) free(p) +#endif + +#ifndef STBI_REALLOC_SIZED +#define STBI_REALLOC_SIZED(p,oldsz,newsz) STBI_REALLOC(p,newsz) +#endif + +// x86/x64 detection +#if defined(__x86_64__) || defined(_M_X64) +#define STBI__X64_TARGET +#elif defined(__i386) || defined(_M_IX86) +#define STBI__X86_TARGET +#endif + +#if defined(__GNUC__) && defined(STBI__X86_TARGET) && !defined(__SSE2__) && !defined(STBI_NO_SIMD) +// gcc doesn't support sse2 intrinsics unless you compile with -msse2, +// which in turn means it gets to use SSE2 everywhere. This is unfortunate, +// but previous attempts to provide the SSE2 functions with runtime +// detection caused numerous issues. The way architecture extensions are +// exposed in GCC/Clang is, sadly, not really suited for one-file libs. +// New behavior: if compiled with -msse2, we use SSE2 without any +// detection; if not, we don't use it at all. +#define STBI_NO_SIMD +#endif + +#if defined(__MINGW32__) && defined(STBI__X86_TARGET) && !defined(STBI_MINGW_ENABLE_SSE2) && !defined(STBI_NO_SIMD) +// Note that __MINGW32__ doesn't actually mean 32-bit, so we have to avoid STBI__X64_TARGET +// +// 32-bit MinGW wants ESP to be 16-byte aligned, but this is not in the +// Windows ABI and VC++ as well as Windows DLLs don't maintain that invariant. +// As a result, enabling SSE2 on 32-bit MinGW is dangerous when not +// simultaneously enabling "-mstackrealign". +// +// See https://github.com/nothings/stb/issues/81 for more information. +// +// So default to no SSE2 on 32-bit MinGW. If you've read this far and added +// -mstackrealign to your build settings, feel free to #define STBI_MINGW_ENABLE_SSE2. +#define STBI_NO_SIMD +#endif + +#if !defined(STBI_NO_SIMD) && (defined(STBI__X86_TARGET) || defined(STBI__X64_TARGET)) +#define STBI_SSE2 +#include + +#ifdef _MSC_VER + +#if _MSC_VER >= 1400 // not VC6 +#include // __cpuid +static int stbi__cpuid3(void) +{ + int info[4]; + __cpuid(info,1); + return info[3]; +} +#else +static int stbi__cpuid3(void) +{ + int res; + __asm { + mov eax,1 + cpuid + mov res,edx + } + return res; +} +#endif + +#define STBI_SIMD_ALIGN(type, name) __declspec(align(16)) type name + +#if !defined(STBI_NO_JPEG) && defined(STBI_SSE2) +static int stbi__sse2_available(void) +{ + int info3 = stbi__cpuid3(); + return ((info3 >> 26) & 1) != 0; +} +#endif + +#else // assume GCC-style if not VC++ +#define STBI_SIMD_ALIGN(type, name) type name __attribute__((aligned(16))) + +#if !defined(STBI_NO_JPEG) && defined(STBI_SSE2) +static int stbi__sse2_available(void) +{ + // If we're even attempting to compile this on GCC/Clang, that means + // -msse2 is on, which means the compiler is allowed to use SSE2 + // instructions at will, and so are we. + return 1; +} +#endif + +#endif +#endif + +// ARM NEON +#if defined(STBI_NO_SIMD) && defined(STBI_NEON) +#undef STBI_NEON +#endif + +#ifdef STBI_NEON +#include +// assume GCC or Clang on ARM targets +#define STBI_SIMD_ALIGN(type, name) type name __attribute__((aligned(16))) +#endif + +#ifndef STBI_SIMD_ALIGN +#define STBI_SIMD_ALIGN(type, name) type name +#endif + +#ifndef STBI_MAX_DIMENSIONS +#define STBI_MAX_DIMENSIONS (1 << 24) +#endif + +/////////////////////////////////////////////// +// +// stbi__context struct and start_xxx functions + +// stbi__context structure is our basic context used by all images, so it +// contains all the IO context, plus some basic image information +typedef struct +{ + stbi__uint32 img_x, img_y; + int img_n, img_out_n; + + stbi_io_callbacks io; + void *io_user_data; + + int read_from_callbacks; + int buflen; + stbi_uc buffer_start[128]; + int callback_already_read; + + stbi_uc *img_buffer, *img_buffer_end; + stbi_uc *img_buffer_original, *img_buffer_original_end; +} stbi__context; + + +static void stbi__refill_buffer(stbi__context *s); + +// initialize a memory-decode context +static void stbi__start_mem(stbi__context *s, stbi_uc const *buffer, int len) +{ + s->io.read = NULL; + s->read_from_callbacks = 0; + s->callback_already_read = 0; + s->img_buffer = s->img_buffer_original = (stbi_uc *) buffer; + s->img_buffer_end = s->img_buffer_original_end = (stbi_uc *) buffer+len; +} + +// initialize a callback-based context +static void stbi__start_callbacks(stbi__context *s, stbi_io_callbacks *c, void *user) +{ + s->io = *c; + s->io_user_data = user; + s->buflen = sizeof(s->buffer_start); + s->read_from_callbacks = 1; + s->callback_already_read = 0; + s->img_buffer = s->img_buffer_original = s->buffer_start; + stbi__refill_buffer(s); + s->img_buffer_original_end = s->img_buffer_end; +} + +#ifndef STBI_NO_STDIO + +static int stbi__stdio_read(void *user, char *data, int size) +{ + return (int) fread(data,1,size,(FILE*) user); +} + +static void stbi__stdio_skip(void *user, int n) +{ + int ch; + fseek((FILE*) user, n, SEEK_CUR); + ch = fgetc((FILE*) user); /* have to read a byte to reset feof()'s flag */ + if (ch != EOF) { + ungetc(ch, (FILE *) user); /* push byte back onto stream if valid. */ + } +} + +static int stbi__stdio_eof(void *user) +{ + return feof((FILE*) user) || ferror((FILE *) user); +} + +static stbi_io_callbacks stbi__stdio_callbacks = +{ + stbi__stdio_read, + stbi__stdio_skip, + stbi__stdio_eof, +}; + +static void stbi__start_file(stbi__context *s, FILE *f) +{ + stbi__start_callbacks(s, &stbi__stdio_callbacks, (void *) f); +} + +//static void stop_file(stbi__context *s) { } + +#endif // !STBI_NO_STDIO + +static void stbi__rewind(stbi__context *s) +{ + // conceptually rewind SHOULD rewind to the beginning of the stream, + // but we just rewind to the beginning of the initial buffer, because + // we only use it after doing 'test', which only ever looks at at most 92 bytes + s->img_buffer = s->img_buffer_original; + s->img_buffer_end = s->img_buffer_original_end; +} + +enum +{ + STBI_ORDER_RGB, + STBI_ORDER_BGR +}; + +typedef struct +{ + int bits_per_channel; + int num_channels; + int channel_order; +} stbi__result_info; + +#ifndef STBI_NO_JPEG +static int stbi__jpeg_test(stbi__context *s); +static void *stbi__jpeg_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static int stbi__jpeg_info(stbi__context *s, int *x, int *y, int *comp); +#endif + +#ifndef STBI_NO_PNG +static int stbi__png_test(stbi__context *s); +static void *stbi__png_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static int stbi__png_info(stbi__context *s, int *x, int *y, int *comp); +static int stbi__png_is16(stbi__context *s); +#endif + +#ifndef STBI_NO_BMP +static int stbi__bmp_test(stbi__context *s); +static void *stbi__bmp_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static int stbi__bmp_info(stbi__context *s, int *x, int *y, int *comp); +#endif + +#ifndef STBI_NO_TGA +static int stbi__tga_test(stbi__context *s); +static void *stbi__tga_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static int stbi__tga_info(stbi__context *s, int *x, int *y, int *comp); +#endif + +#ifndef STBI_NO_PSD +static int stbi__psd_test(stbi__context *s); +static void *stbi__psd_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri, int bpc); +static int stbi__psd_info(stbi__context *s, int *x, int *y, int *comp); +static int stbi__psd_is16(stbi__context *s); +#endif + +#ifndef STBI_NO_HDR +static int stbi__hdr_test(stbi__context *s); +static float *stbi__hdr_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static int stbi__hdr_info(stbi__context *s, int *x, int *y, int *comp); +#endif + +#ifndef STBI_NO_PIC +static int stbi__pic_test(stbi__context *s); +static void *stbi__pic_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static int stbi__pic_info(stbi__context *s, int *x, int *y, int *comp); +#endif + +#ifndef STBI_NO_GIF +static int stbi__gif_test(stbi__context *s); +static void *stbi__gif_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static void *stbi__load_gif_main(stbi__context *s, int **delays, int *x, int *y, int *z, int *comp, int req_comp); +static int stbi__gif_info(stbi__context *s, int *x, int *y, int *comp); +#endif + +#ifndef STBI_NO_PNM +static int stbi__pnm_test(stbi__context *s); +static void *stbi__pnm_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri); +static int stbi__pnm_info(stbi__context *s, int *x, int *y, int *comp); +#endif + +static +#ifdef STBI_THREAD_LOCAL +STBI_THREAD_LOCAL +#endif +const char *stbi__g_failure_reason; + +STBIDEF const char *stbi_failure_reason(void) +{ + return stbi__g_failure_reason; +} + +#ifndef STBI_NO_FAILURE_STRINGS +static int stbi__err(const char *str) +{ + stbi__g_failure_reason = str; + return 0; +} +#endif + +static void *stbi__malloc(size_t size) +{ + return STBI_MALLOC(size); +} + +// stb_image uses ints pervasively, including for offset calculations. +// therefore the largest decoded image size we can support with the +// current code, even on 64-bit targets, is INT_MAX. this is not a +// significant limitation for the intended use case. +// +// we do, however, need to make sure our size calculations don't +// overflow. hence a few helper functions for size calculations that +// multiply integers together, making sure that they're non-negative +// and no overflow occurs. + +// return 1 if the sum is valid, 0 on overflow. +// negative terms are considered invalid. +static int stbi__addsizes_valid(int a, int b) +{ + if (b < 0) return 0; + // now 0 <= b <= INT_MAX, hence also + // 0 <= INT_MAX - b <= INTMAX. + // And "a + b <= INT_MAX" (which might overflow) is the + // same as a <= INT_MAX - b (no overflow) + return a <= INT_MAX - b; +} + +// returns 1 if the product is valid, 0 on overflow. +// negative factors are considered invalid. +static int stbi__mul2sizes_valid(int a, int b) +{ + if (a < 0 || b < 0) return 0; + if (b == 0) return 1; // mul-by-0 is always safe + // portable way to check for no overflows in a*b + return a <= INT_MAX/b; +} + +#if !defined(STBI_NO_JPEG) || !defined(STBI_NO_PNG) || !defined(STBI_NO_TGA) || !defined(STBI_NO_HDR) +// returns 1 if "a*b + add" has no negative terms/factors and doesn't overflow +static int stbi__mad2sizes_valid(int a, int b, int add) +{ + return stbi__mul2sizes_valid(a, b) && stbi__addsizes_valid(a*b, add); +} +#endif + +// returns 1 if "a*b*c + add" has no negative terms/factors and doesn't overflow +static int stbi__mad3sizes_valid(int a, int b, int c, int add) +{ + return stbi__mul2sizes_valid(a, b) && stbi__mul2sizes_valid(a*b, c) && + stbi__addsizes_valid(a*b*c, add); +} + +// returns 1 if "a*b*c*d + add" has no negative terms/factors and doesn't overflow +#if !defined(STBI_NO_LINEAR) || !defined(STBI_NO_HDR) +static int stbi__mad4sizes_valid(int a, int b, int c, int d, int add) +{ + return stbi__mul2sizes_valid(a, b) && stbi__mul2sizes_valid(a*b, c) && + stbi__mul2sizes_valid(a*b*c, d) && stbi__addsizes_valid(a*b*c*d, add); +} +#endif + +#if !defined(STBI_NO_JPEG) || !defined(STBI_NO_PNG) || !defined(STBI_NO_TGA) || !defined(STBI_NO_HDR) +// mallocs with size overflow checking +static void *stbi__malloc_mad2(int a, int b, int add) +{ + if (!stbi__mad2sizes_valid(a, b, add)) return NULL; + return stbi__malloc(a*b + add); +} +#endif + +static void *stbi__malloc_mad3(int a, int b, int c, int add) +{ + if (!stbi__mad3sizes_valid(a, b, c, add)) return NULL; + return stbi__malloc(a*b*c + add); +} + +#if !defined(STBI_NO_LINEAR) || !defined(STBI_NO_HDR) +static void *stbi__malloc_mad4(int a, int b, int c, int d, int add) +{ + if (!stbi__mad4sizes_valid(a, b, c, d, add)) return NULL; + return stbi__malloc(a*b*c*d + add); +} +#endif + +// stbi__err - error +// stbi__errpf - error returning pointer to float +// stbi__errpuc - error returning pointer to unsigned char + +#ifdef STBI_NO_FAILURE_STRINGS + #define stbi__err(x,y) 0 +#elif defined(STBI_FAILURE_USERMSG) + #define stbi__err(x,y) stbi__err(y) +#else + #define stbi__err(x,y) stbi__err(x) +#endif + +#define stbi__errpf(x,y) ((float *)(size_t) (stbi__err(x,y)?NULL:NULL)) +#define stbi__errpuc(x,y) ((unsigned char *)(size_t) (stbi__err(x,y)?NULL:NULL)) + +STBIDEF void stbi_image_free(void *retval_from_stbi_load) +{ + STBI_FREE(retval_from_stbi_load); +} + +#ifndef STBI_NO_LINEAR +static float *stbi__ldr_to_hdr(stbi_uc *data, int x, int y, int comp); +#endif + +#ifndef STBI_NO_HDR +static stbi_uc *stbi__hdr_to_ldr(float *data, int x, int y, int comp); +#endif + +static int stbi__vertically_flip_on_load_global = 0; + +STBIDEF void stbi_set_flip_vertically_on_load(int flag_true_if_should_flip) +{ + stbi__vertically_flip_on_load_global = flag_true_if_should_flip; +} + +#ifndef STBI_THREAD_LOCAL +#define stbi__vertically_flip_on_load stbi__vertically_flip_on_load_global +#else +static STBI_THREAD_LOCAL int stbi__vertically_flip_on_load_local, stbi__vertically_flip_on_load_set; + +STBIDEF void stbi_set_flip_vertically_on_load_thread(int flag_true_if_should_flip) +{ + stbi__vertically_flip_on_load_local = flag_true_if_should_flip; + stbi__vertically_flip_on_load_set = 1; +} + +#define stbi__vertically_flip_on_load (stbi__vertically_flip_on_load_set \ + ? stbi__vertically_flip_on_load_local \ + : stbi__vertically_flip_on_load_global) +#endif // STBI_THREAD_LOCAL + +static void *stbi__load_main(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri, int bpc) +{ + memset(ri, 0, sizeof(*ri)); // make sure it's initialized if we add new fields + ri->bits_per_channel = 8; // default is 8 so most paths don't have to be changed + ri->channel_order = STBI_ORDER_RGB; // all current input & output are this, but this is here so we can add BGR order + ri->num_channels = 0; + + #ifndef STBI_NO_JPEG + if (stbi__jpeg_test(s)) return stbi__jpeg_load(s,x,y,comp,req_comp, ri); + #endif + #ifndef STBI_NO_PNG + if (stbi__png_test(s)) return stbi__png_load(s,x,y,comp,req_comp, ri); + #endif + #ifndef STBI_NO_BMP + if (stbi__bmp_test(s)) return stbi__bmp_load(s,x,y,comp,req_comp, ri); + #endif + #ifndef STBI_NO_GIF + if (stbi__gif_test(s)) return stbi__gif_load(s,x,y,comp,req_comp, ri); + #endif + #ifndef STBI_NO_PSD + if (stbi__psd_test(s)) return stbi__psd_load(s,x,y,comp,req_comp, ri, bpc); + #else + STBI_NOTUSED(bpc); + #endif + #ifndef STBI_NO_PIC + if (stbi__pic_test(s)) return stbi__pic_load(s,x,y,comp,req_comp, ri); + #endif + #ifndef STBI_NO_PNM + if (stbi__pnm_test(s)) return stbi__pnm_load(s,x,y,comp,req_comp, ri); + #endif + + #ifndef STBI_NO_HDR + if (stbi__hdr_test(s)) { + float *hdr = stbi__hdr_load(s, x,y,comp,req_comp, ri); + return stbi__hdr_to_ldr(hdr, *x, *y, req_comp ? req_comp : *comp); + } + #endif + + #ifndef STBI_NO_TGA + // test tga last because it's a crappy test! + if (stbi__tga_test(s)) + return stbi__tga_load(s,x,y,comp,req_comp, ri); + #endif + + return stbi__errpuc("unknown image type", "Image not of any known type, or corrupt"); +} + +static stbi_uc *stbi__convert_16_to_8(stbi__uint16 *orig, int w, int h, int channels) +{ + int i; + int img_len = w * h * channels; + stbi_uc *reduced; + + reduced = (stbi_uc *) stbi__malloc(img_len); + if (reduced == NULL) return stbi__errpuc("outofmem", "Out of memory"); + + for (i = 0; i < img_len; ++i) + reduced[i] = (stbi_uc)((orig[i] >> 8) & 0xFF); // top half of each byte is sufficient approx of 16->8 bit scaling + + STBI_FREE(orig); + return reduced; +} + +static stbi__uint16 *stbi__convert_8_to_16(stbi_uc *orig, int w, int h, int channels) +{ + int i; + int img_len = w * h * channels; + stbi__uint16 *enlarged; + + enlarged = (stbi__uint16 *) stbi__malloc(img_len*2); + if (enlarged == NULL) return (stbi__uint16 *) stbi__errpuc("outofmem", "Out of memory"); + + for (i = 0; i < img_len; ++i) + enlarged[i] = (stbi__uint16)((orig[i] << 8) + orig[i]); // replicate to high and low byte, maps 0->0, 255->0xffff + + STBI_FREE(orig); + return enlarged; +} + +static void stbi__vertical_flip(void *image, int w, int h, int bytes_per_pixel) +{ + int row; + size_t bytes_per_row = (size_t)w * bytes_per_pixel; + stbi_uc temp[2048]; + stbi_uc *bytes = (stbi_uc *)image; + + for (row = 0; row < (h>>1); row++) { + stbi_uc *row0 = bytes + row*bytes_per_row; + stbi_uc *row1 = bytes + (h - row - 1)*bytes_per_row; + // swap row0 with row1 + size_t bytes_left = bytes_per_row; + while (bytes_left) { + size_t bytes_copy = (bytes_left < sizeof(temp)) ? bytes_left : sizeof(temp); + memcpy(temp, row0, bytes_copy); + memcpy(row0, row1, bytes_copy); + memcpy(row1, temp, bytes_copy); + row0 += bytes_copy; + row1 += bytes_copy; + bytes_left -= bytes_copy; + } + } +} + +#ifndef STBI_NO_GIF +static void stbi__vertical_flip_slices(void *image, int w, int h, int z, int bytes_per_pixel) +{ + int slice; + int slice_size = w * h * bytes_per_pixel; + + stbi_uc *bytes = (stbi_uc *)image; + for (slice = 0; slice < z; ++slice) { + stbi__vertical_flip(bytes, w, h, bytes_per_pixel); + bytes += slice_size; + } +} +#endif + +static unsigned char *stbi__load_and_postprocess_8bit(stbi__context *s, int *x, int *y, int *comp, int req_comp) +{ + stbi__result_info ri; + void *result = stbi__load_main(s, x, y, comp, req_comp, &ri, 8); + + if (result == NULL) + return NULL; + + // it is the responsibility of the loaders to make sure we get either 8 or 16 bit. + STBI_ASSERT(ri.bits_per_channel == 8 || ri.bits_per_channel == 16); + + if (ri.bits_per_channel != 8) { + result = stbi__convert_16_to_8((stbi__uint16 *) result, *x, *y, req_comp == 0 ? *comp : req_comp); + ri.bits_per_channel = 8; + } + + // @TODO: move stbi__convert_format to here + + if (stbi__vertically_flip_on_load) { + int channels = req_comp ? req_comp : *comp; + stbi__vertical_flip(result, *x, *y, channels * sizeof(stbi_uc)); + } + + return (unsigned char *) result; +} + +static stbi__uint16 *stbi__load_and_postprocess_16bit(stbi__context *s, int *x, int *y, int *comp, int req_comp) +{ + stbi__result_info ri; + void *result = stbi__load_main(s, x, y, comp, req_comp, &ri, 16); + + if (result == NULL) + return NULL; + + // it is the responsibility of the loaders to make sure we get either 8 or 16 bit. + STBI_ASSERT(ri.bits_per_channel == 8 || ri.bits_per_channel == 16); + + if (ri.bits_per_channel != 16) { + result = stbi__convert_8_to_16((stbi_uc *) result, *x, *y, req_comp == 0 ? *comp : req_comp); + ri.bits_per_channel = 16; + } + + // @TODO: move stbi__convert_format16 to here + // @TODO: special case RGB-to-Y (and RGBA-to-YA) for 8-bit-to-16-bit case to keep more precision + + if (stbi__vertically_flip_on_load) { + int channels = req_comp ? req_comp : *comp; + stbi__vertical_flip(result, *x, *y, channels * sizeof(stbi__uint16)); + } + + return (stbi__uint16 *) result; +} + +#if !defined(STBI_NO_HDR) && !defined(STBI_NO_LINEAR) +static void stbi__float_postprocess(float *result, int *x, int *y, int *comp, int req_comp) +{ + if (stbi__vertically_flip_on_load && result != NULL) { + int channels = req_comp ? req_comp : *comp; + stbi__vertical_flip(result, *x, *y, channels * sizeof(float)); + } +} +#endif + +#ifndef STBI_NO_STDIO + +#if defined(_MSC_VER) && defined(STBI_WINDOWS_UTF8) +STBI_EXTERN __declspec(dllimport) int __stdcall MultiByteToWideChar(unsigned int cp, unsigned long flags, const char *str, int cbmb, wchar_t *widestr, int cchwide); +STBI_EXTERN __declspec(dllimport) int __stdcall WideCharToMultiByte(unsigned int cp, unsigned long flags, const wchar_t *widestr, int cchwide, char *str, int cbmb, const char *defchar, int *used_default); +#endif + +#if defined(_MSC_VER) && defined(STBI_WINDOWS_UTF8) +STBIDEF int stbi_convert_wchar_to_utf8(char *buffer, size_t bufferlen, const wchar_t* input) +{ + return WideCharToMultiByte(65001 /* UTF8 */, 0, input, -1, buffer, (int) bufferlen, NULL, NULL); +} +#endif + +static FILE *stbi__fopen(char const *filename, char const *mode) +{ + FILE *f; +#if defined(_MSC_VER) && defined(STBI_WINDOWS_UTF8) + wchar_t wMode[64]; + wchar_t wFilename[1024]; + if (0 == MultiByteToWideChar(65001 /* UTF8 */, 0, filename, -1, wFilename, sizeof(wFilename))) + return 0; + + if (0 == MultiByteToWideChar(65001 /* UTF8 */, 0, mode, -1, wMode, sizeof(wMode))) + return 0; + +#if _MSC_VER >= 1400 + if (0 != _wfopen_s(&f, wFilename, wMode)) + f = 0; +#else + f = _wfopen(wFilename, wMode); +#endif + +#elif defined(_MSC_VER) && _MSC_VER >= 1400 + if (0 != fopen_s(&f, filename, mode)) + f=0; +#else + f = fopen(filename, mode); +#endif + return f; +} + + +STBIDEF stbi_uc *stbi_load(char const *filename, int *x, int *y, int *comp, int req_comp) +{ + FILE *f = stbi__fopen(filename, "rb"); + unsigned char *result; + if (!f) return stbi__errpuc("can't fopen", "Unable to open file"); + result = stbi_load_from_file(f,x,y,comp,req_comp); + fclose(f); + return result; +} + +STBIDEF stbi_uc *stbi_load_from_file(FILE *f, int *x, int *y, int *comp, int req_comp) +{ + unsigned char *result; + stbi__context s; + stbi__start_file(&s,f); + result = stbi__load_and_postprocess_8bit(&s,x,y,comp,req_comp); + if (result) { + // need to 'unget' all the characters in the IO buffer + fseek(f, - (int) (s.img_buffer_end - s.img_buffer), SEEK_CUR); + } + return result; +} + +STBIDEF stbi__uint16 *stbi_load_from_file_16(FILE *f, int *x, int *y, int *comp, int req_comp) +{ + stbi__uint16 *result; + stbi__context s; + stbi__start_file(&s,f); + result = stbi__load_and_postprocess_16bit(&s,x,y,comp,req_comp); + if (result) { + // need to 'unget' all the characters in the IO buffer + fseek(f, - (int) (s.img_buffer_end - s.img_buffer), SEEK_CUR); + } + return result; +} + +STBIDEF stbi_us *stbi_load_16(char const *filename, int *x, int *y, int *comp, int req_comp) +{ + FILE *f = stbi__fopen(filename, "rb"); + stbi__uint16 *result; + if (!f) return (stbi_us *) stbi__errpuc("can't fopen", "Unable to open file"); + result = stbi_load_from_file_16(f,x,y,comp,req_comp); + fclose(f); + return result; +} + + +#endif //!STBI_NO_STDIO + +STBIDEF stbi_us *stbi_load_16_from_memory(stbi_uc const *buffer, int len, int *x, int *y, int *channels_in_file, int desired_channels) +{ + stbi__context s; + stbi__start_mem(&s,buffer,len); + return stbi__load_and_postprocess_16bit(&s,x,y,channels_in_file,desired_channels); +} + +STBIDEF stbi_us *stbi_load_16_from_callbacks(stbi_io_callbacks const *clbk, void *user, int *x, int *y, int *channels_in_file, int desired_channels) +{ + stbi__context s; + stbi__start_callbacks(&s, (stbi_io_callbacks *)clbk, user); + return stbi__load_and_postprocess_16bit(&s,x,y,channels_in_file,desired_channels); +} + +STBIDEF stbi_uc *stbi_load_from_memory(stbi_uc const *buffer, int len, int *x, int *y, int *comp, int req_comp) +{ + stbi__context s; + stbi__start_mem(&s,buffer,len); + return stbi__load_and_postprocess_8bit(&s,x,y,comp,req_comp); +} + +STBIDEF stbi_uc *stbi_load_from_callbacks(stbi_io_callbacks const *clbk, void *user, int *x, int *y, int *comp, int req_comp) +{ + stbi__context s; + stbi__start_callbacks(&s, (stbi_io_callbacks *) clbk, user); + return stbi__load_and_postprocess_8bit(&s,x,y,comp,req_comp); +} + +#ifndef STBI_NO_GIF +STBIDEF stbi_uc *stbi_load_gif_from_memory(stbi_uc const *buffer, int len, int **delays, int *x, int *y, int *z, int *comp, int req_comp) +{ + unsigned char *result; + stbi__context s; + stbi__start_mem(&s,buffer,len); + + result = (unsigned char*) stbi__load_gif_main(&s, delays, x, y, z, comp, req_comp); + if (stbi__vertically_flip_on_load) { + stbi__vertical_flip_slices( result, *x, *y, *z, *comp ); + } + + return result; +} +#endif + +#ifndef STBI_NO_LINEAR +static float *stbi__loadf_main(stbi__context *s, int *x, int *y, int *comp, int req_comp) +{ + unsigned char *data; + #ifndef STBI_NO_HDR + if (stbi__hdr_test(s)) { + stbi__result_info ri; + float *hdr_data = stbi__hdr_load(s,x,y,comp,req_comp, &ri); + if (hdr_data) + stbi__float_postprocess(hdr_data,x,y,comp,req_comp); + return hdr_data; + } + #endif + data = stbi__load_and_postprocess_8bit(s, x, y, comp, req_comp); + if (data) + return stbi__ldr_to_hdr(data, *x, *y, req_comp ? req_comp : *comp); + return stbi__errpf("unknown image type", "Image not of any known type, or corrupt"); +} + +STBIDEF float *stbi_loadf_from_memory(stbi_uc const *buffer, int len, int *x, int *y, int *comp, int req_comp) +{ + stbi__context s; + stbi__start_mem(&s,buffer,len); + return stbi__loadf_main(&s,x,y,comp,req_comp); +} + +STBIDEF float *stbi_loadf_from_callbacks(stbi_io_callbacks const *clbk, void *user, int *x, int *y, int *comp, int req_comp) +{ + stbi__context s; + stbi__start_callbacks(&s, (stbi_io_callbacks *) clbk, user); + return stbi__loadf_main(&s,x,y,comp,req_comp); +} + +#ifndef STBI_NO_STDIO +STBIDEF float *stbi_loadf(char const *filename, int *x, int *y, int *comp, int req_comp) +{ + float *result; + FILE *f = stbi__fopen(filename, "rb"); + if (!f) return stbi__errpf("can't fopen", "Unable to open file"); + result = stbi_loadf_from_file(f,x,y,comp,req_comp); + fclose(f); + return result; +} + +STBIDEF float *stbi_loadf_from_file(FILE *f, int *x, int *y, int *comp, int req_comp) +{ + stbi__context s; + stbi__start_file(&s,f); + return stbi__loadf_main(&s,x,y,comp,req_comp); +} +#endif // !STBI_NO_STDIO + +#endif // !STBI_NO_LINEAR + +// these is-hdr-or-not is defined independent of whether STBI_NO_LINEAR is +// defined, for API simplicity; if STBI_NO_LINEAR is defined, it always +// reports false! + +STBIDEF int stbi_is_hdr_from_memory(stbi_uc const *buffer, int len) +{ + #ifndef STBI_NO_HDR + stbi__context s; + stbi__start_mem(&s,buffer,len); + return stbi__hdr_test(&s); + #else + STBI_NOTUSED(buffer); + STBI_NOTUSED(len); + return 0; + #endif +} + +#ifndef STBI_NO_STDIO +STBIDEF int stbi_is_hdr (char const *filename) +{ + FILE *f = stbi__fopen(filename, "rb"); + int result=0; + if (f) { + result = stbi_is_hdr_from_file(f); + fclose(f); + } + return result; +} + +STBIDEF int stbi_is_hdr_from_file(FILE *f) +{ + #ifndef STBI_NO_HDR + long pos = ftell(f); + int res; + stbi__context s; + stbi__start_file(&s,f); + res = stbi__hdr_test(&s); + fseek(f, pos, SEEK_SET); + return res; + #else + STBI_NOTUSED(f); + return 0; + #endif +} +#endif // !STBI_NO_STDIO + +STBIDEF int stbi_is_hdr_from_callbacks(stbi_io_callbacks const *clbk, void *user) +{ + #ifndef STBI_NO_HDR + stbi__context s; + stbi__start_callbacks(&s, (stbi_io_callbacks *) clbk, user); + return stbi__hdr_test(&s); + #else + STBI_NOTUSED(clbk); + STBI_NOTUSED(user); + return 0; + #endif +} + +#ifndef STBI_NO_LINEAR +static float stbi__l2h_gamma=2.2f, stbi__l2h_scale=1.0f; + +STBIDEF void stbi_ldr_to_hdr_gamma(float gamma) { stbi__l2h_gamma = gamma; } +STBIDEF void stbi_ldr_to_hdr_scale(float scale) { stbi__l2h_scale = scale; } +#endif + +static float stbi__h2l_gamma_i=1.0f/2.2f, stbi__h2l_scale_i=1.0f; + +STBIDEF void stbi_hdr_to_ldr_gamma(float gamma) { stbi__h2l_gamma_i = 1/gamma; } +STBIDEF void stbi_hdr_to_ldr_scale(float scale) { stbi__h2l_scale_i = 1/scale; } + + +////////////////////////////////////////////////////////////////////////////// +// +// Common code used by all image loaders +// + +enum +{ + STBI__SCAN_load=0, + STBI__SCAN_type, + STBI__SCAN_header +}; + +static void stbi__refill_buffer(stbi__context *s) +{ + int n = (s->io.read)(s->io_user_data,(char*)s->buffer_start,s->buflen); + s->callback_already_read += (int) (s->img_buffer - s->img_buffer_original); + if (n == 0) { + // at end of file, treat same as if from memory, but need to handle case + // where s->img_buffer isn't pointing to safe memory, e.g. 0-byte file + s->read_from_callbacks = 0; + s->img_buffer = s->buffer_start; + s->img_buffer_end = s->buffer_start+1; + *s->img_buffer = 0; + } else { + s->img_buffer = s->buffer_start; + s->img_buffer_end = s->buffer_start + n; + } +} + +stbi_inline static stbi_uc stbi__get8(stbi__context *s) +{ + if (s->img_buffer < s->img_buffer_end) + return *s->img_buffer++; + if (s->read_from_callbacks) { + stbi__refill_buffer(s); + return *s->img_buffer++; + } + return 0; +} + +#if defined(STBI_NO_JPEG) && defined(STBI_NO_HDR) && defined(STBI_NO_PIC) && defined(STBI_NO_PNM) +// nothing +#else +stbi_inline static int stbi__at_eof(stbi__context *s) +{ + if (s->io.read) { + if (!(s->io.eof)(s->io_user_data)) return 0; + // if feof() is true, check if buffer = end + // special case: we've only got the special 0 character at the end + if (s->read_from_callbacks == 0) return 1; + } + + return s->img_buffer >= s->img_buffer_end; +} +#endif + +#if defined(STBI_NO_JPEG) && defined(STBI_NO_PNG) && defined(STBI_NO_BMP) && defined(STBI_NO_PSD) && defined(STBI_NO_TGA) && defined(STBI_NO_GIF) && defined(STBI_NO_PIC) +// nothing +#else +static void stbi__skip(stbi__context *s, int n) +{ + if (n == 0) return; // already there! + if (n < 0) { + s->img_buffer = s->img_buffer_end; + return; + } + if (s->io.read) { + int blen = (int) (s->img_buffer_end - s->img_buffer); + if (blen < n) { + s->img_buffer = s->img_buffer_end; + (s->io.skip)(s->io_user_data, n - blen); + return; + } + } + s->img_buffer += n; +} +#endif + +#if defined(STBI_NO_PNG) && defined(STBI_NO_TGA) && defined(STBI_NO_HDR) && defined(STBI_NO_PNM) +// nothing +#else +static int stbi__getn(stbi__context *s, stbi_uc *buffer, int n) +{ + if (s->io.read) { + int blen = (int) (s->img_buffer_end - s->img_buffer); + if (blen < n) { + int res, count; + + memcpy(buffer, s->img_buffer, blen); + + count = (s->io.read)(s->io_user_data, (char*) buffer + blen, n - blen); + res = (count == (n-blen)); + s->img_buffer = s->img_buffer_end; + return res; + } + } + + if (s->img_buffer+n <= s->img_buffer_end) { + memcpy(buffer, s->img_buffer, n); + s->img_buffer += n; + return 1; + } else + return 0; +} +#endif + +#if defined(STBI_NO_JPEG) && defined(STBI_NO_PNG) && defined(STBI_NO_PSD) && defined(STBI_NO_PIC) +// nothing +#else +static int stbi__get16be(stbi__context *s) +{ + int z = stbi__get8(s); + return (z << 8) + stbi__get8(s); +} +#endif + +#if defined(STBI_NO_PNG) && defined(STBI_NO_PSD) && defined(STBI_NO_PIC) +// nothing +#else +static stbi__uint32 stbi__get32be(stbi__context *s) +{ + stbi__uint32 z = stbi__get16be(s); + return (z << 16) + stbi__get16be(s); +} +#endif + +#if defined(STBI_NO_BMP) && defined(STBI_NO_TGA) && defined(STBI_NO_GIF) +// nothing +#else +static int stbi__get16le(stbi__context *s) +{ + int z = stbi__get8(s); + return z + (stbi__get8(s) << 8); +} +#endif + +#ifndef STBI_NO_BMP +static stbi__uint32 stbi__get32le(stbi__context *s) +{ + stbi__uint32 z = stbi__get16le(s); + return z + (stbi__get16le(s) << 16); +} +#endif + +#define STBI__BYTECAST(x) ((stbi_uc) ((x) & 255)) // truncate int to byte without warnings + +#if defined(STBI_NO_JPEG) && defined(STBI_NO_PNG) && defined(STBI_NO_BMP) && defined(STBI_NO_PSD) && defined(STBI_NO_TGA) && defined(STBI_NO_GIF) && defined(STBI_NO_PIC) && defined(STBI_NO_PNM) +// nothing +#else +////////////////////////////////////////////////////////////////////////////// +// +// generic converter from built-in img_n to req_comp +// individual types do this automatically as much as possible (e.g. jpeg +// does all cases internally since it needs to colorspace convert anyway, +// and it never has alpha, so very few cases ). png can automatically +// interleave an alpha=255 channel, but falls back to this for other cases +// +// assume data buffer is malloced, so malloc a new one and free that one +// only failure mode is malloc failing + +static stbi_uc stbi__compute_y(int r, int g, int b) +{ + return (stbi_uc) (((r*77) + (g*150) + (29*b)) >> 8); +} +#endif + +#if defined(STBI_NO_PNG) && defined(STBI_NO_BMP) && defined(STBI_NO_PSD) && defined(STBI_NO_TGA) && defined(STBI_NO_GIF) && defined(STBI_NO_PIC) && defined(STBI_NO_PNM) +// nothing +#else +static unsigned char *stbi__convert_format(unsigned char *data, int img_n, int req_comp, unsigned int x, unsigned int y) +{ + int i,j; + unsigned char *good; + + if (req_comp == img_n) return data; + STBI_ASSERT(req_comp >= 1 && req_comp <= 4); + + good = (unsigned char *) stbi__malloc_mad3(req_comp, x, y, 0); + if (good == NULL) { + STBI_FREE(data); + return stbi__errpuc("outofmem", "Out of memory"); + } + + for (j=0; j < (int) y; ++j) { + unsigned char *src = data + j * x * img_n ; + unsigned char *dest = good + j * x * req_comp; + + #define STBI__COMBO(a,b) ((a)*8+(b)) + #define STBI__CASE(a,b) case STBI__COMBO(a,b): for(i=x-1; i >= 0; --i, src += a, dest += b) + // convert source image with img_n components to one with req_comp components; + // avoid switch per pixel, so use switch per scanline and massive macros + switch (STBI__COMBO(img_n, req_comp)) { + STBI__CASE(1,2) { dest[0]=src[0]; dest[1]=255; } break; + STBI__CASE(1,3) { dest[0]=dest[1]=dest[2]=src[0]; } break; + STBI__CASE(1,4) { dest[0]=dest[1]=dest[2]=src[0]; dest[3]=255; } break; + STBI__CASE(2,1) { dest[0]=src[0]; } break; + STBI__CASE(2,3) { dest[0]=dest[1]=dest[2]=src[0]; } break; + STBI__CASE(2,4) { dest[0]=dest[1]=dest[2]=src[0]; dest[3]=src[1]; } break; + STBI__CASE(3,4) { dest[0]=src[0];dest[1]=src[1];dest[2]=src[2];dest[3]=255; } break; + STBI__CASE(3,1) { dest[0]=stbi__compute_y(src[0],src[1],src[2]); } break; + STBI__CASE(3,2) { dest[0]=stbi__compute_y(src[0],src[1],src[2]); dest[1] = 255; } break; + STBI__CASE(4,1) { dest[0]=stbi__compute_y(src[0],src[1],src[2]); } break; + STBI__CASE(4,2) { dest[0]=stbi__compute_y(src[0],src[1],src[2]); dest[1] = src[3]; } break; + STBI__CASE(4,3) { dest[0]=src[0];dest[1]=src[1];dest[2]=src[2]; } break; + default: STBI_ASSERT(0); STBI_FREE(data); STBI_FREE(good); return stbi__errpuc("unsupported", "Unsupported format conversion"); + } + #undef STBI__CASE + } + + STBI_FREE(data); + return good; +} +#endif + +#if defined(STBI_NO_PNG) && defined(STBI_NO_PSD) +// nothing +#else +static stbi__uint16 stbi__compute_y_16(int r, int g, int b) +{ + return (stbi__uint16) (((r*77) + (g*150) + (29*b)) >> 8); +} +#endif + +#if defined(STBI_NO_PNG) && defined(STBI_NO_PSD) +// nothing +#else +static stbi__uint16 *stbi__convert_format16(stbi__uint16 *data, int img_n, int req_comp, unsigned int x, unsigned int y) +{ + int i,j; + stbi__uint16 *good; + + if (req_comp == img_n) return data; + STBI_ASSERT(req_comp >= 1 && req_comp <= 4); + + good = (stbi__uint16 *) stbi__malloc(req_comp * x * y * 2); + if (good == NULL) { + STBI_FREE(data); + return (stbi__uint16 *) stbi__errpuc("outofmem", "Out of memory"); + } + + for (j=0; j < (int) y; ++j) { + stbi__uint16 *src = data + j * x * img_n ; + stbi__uint16 *dest = good + j * x * req_comp; + + #define STBI__COMBO(a,b) ((a)*8+(b)) + #define STBI__CASE(a,b) case STBI__COMBO(a,b): for(i=x-1; i >= 0; --i, src += a, dest += b) + // convert source image with img_n components to one with req_comp components; + // avoid switch per pixel, so use switch per scanline and massive macros + switch (STBI__COMBO(img_n, req_comp)) { + STBI__CASE(1,2) { dest[0]=src[0]; dest[1]=0xffff; } break; + STBI__CASE(1,3) { dest[0]=dest[1]=dest[2]=src[0]; } break; + STBI__CASE(1,4) { dest[0]=dest[1]=dest[2]=src[0]; dest[3]=0xffff; } break; + STBI__CASE(2,1) { dest[0]=src[0]; } break; + STBI__CASE(2,3) { dest[0]=dest[1]=dest[2]=src[0]; } break; + STBI__CASE(2,4) { dest[0]=dest[1]=dest[2]=src[0]; dest[3]=src[1]; } break; + STBI__CASE(3,4) { dest[0]=src[0];dest[1]=src[1];dest[2]=src[2];dest[3]=0xffff; } break; + STBI__CASE(3,1) { dest[0]=stbi__compute_y_16(src[0],src[1],src[2]); } break; + STBI__CASE(3,2) { dest[0]=stbi__compute_y_16(src[0],src[1],src[2]); dest[1] = 0xffff; } break; + STBI__CASE(4,1) { dest[0]=stbi__compute_y_16(src[0],src[1],src[2]); } break; + STBI__CASE(4,2) { dest[0]=stbi__compute_y_16(src[0],src[1],src[2]); dest[1] = src[3]; } break; + STBI__CASE(4,3) { dest[0]=src[0];dest[1]=src[1];dest[2]=src[2]; } break; + default: STBI_ASSERT(0); STBI_FREE(data); STBI_FREE(good); return (stbi__uint16*) stbi__errpuc("unsupported", "Unsupported format conversion"); + } + #undef STBI__CASE + } + + STBI_FREE(data); + return good; +} +#endif + +#ifndef STBI_NO_LINEAR +static float *stbi__ldr_to_hdr(stbi_uc *data, int x, int y, int comp) +{ + int i,k,n; + float *output; + if (!data) return NULL; + output = (float *) stbi__malloc_mad4(x, y, comp, sizeof(float), 0); + if (output == NULL) { STBI_FREE(data); return stbi__errpf("outofmem", "Out of memory"); } + // compute number of non-alpha components + if (comp & 1) n = comp; else n = comp-1; + for (i=0; i < x*y; ++i) { + for (k=0; k < n; ++k) { + output[i*comp + k] = (float) (pow(data[i*comp+k]/255.0f, stbi__l2h_gamma) * stbi__l2h_scale); + } + } + if (n < comp) { + for (i=0; i < x*y; ++i) { + output[i*comp + n] = data[i*comp + n]/255.0f; + } + } + STBI_FREE(data); + return output; +} +#endif + +#ifndef STBI_NO_HDR +#define stbi__float2int(x) ((int) (x)) +static stbi_uc *stbi__hdr_to_ldr(float *data, int x, int y, int comp) +{ + int i,k,n; + stbi_uc *output; + if (!data) return NULL; + output = (stbi_uc *) stbi__malloc_mad3(x, y, comp, 0); + if (output == NULL) { STBI_FREE(data); return stbi__errpuc("outofmem", "Out of memory"); } + // compute number of non-alpha components + if (comp & 1) n = comp; else n = comp-1; + for (i=0; i < x*y; ++i) { + for (k=0; k < n; ++k) { + float z = (float) pow(data[i*comp+k]*stbi__h2l_scale_i, stbi__h2l_gamma_i) * 255 + 0.5f; + if (z < 0) z = 0; + if (z > 255) z = 255; + output[i*comp + k] = (stbi_uc) stbi__float2int(z); + } + if (k < comp) { + float z = data[i*comp+k] * 255 + 0.5f; + if (z < 0) z = 0; + if (z > 255) z = 255; + output[i*comp + k] = (stbi_uc) stbi__float2int(z); + } + } + STBI_FREE(data); + return output; +} +#endif + +////////////////////////////////////////////////////////////////////////////// +// +// "baseline" JPEG/JFIF decoder +// +// simple implementation +// - doesn't support delayed output of y-dimension +// - simple interface (only one output format: 8-bit interleaved RGB) +// - doesn't try to recover corrupt jpegs +// - doesn't allow partial loading, loading multiple at once +// - still fast on x86 (copying globals into locals doesn't help x86) +// - allocates lots of intermediate memory (full size of all components) +// - non-interleaved case requires this anyway +// - allows good upsampling (see next) +// high-quality +// - upsampled channels are bilinearly interpolated, even across blocks +// - quality integer IDCT derived from IJG's 'slow' +// performance +// - fast huffman; reasonable integer IDCT +// - some SIMD kernels for common paths on targets with SSE2/NEON +// - uses a lot of intermediate memory, could cache poorly + +#ifndef STBI_NO_JPEG + +// huffman decoding acceleration +#define FAST_BITS 9 // larger handles more cases; smaller stomps less cache + +typedef struct +{ + stbi_uc fast[1 << FAST_BITS]; + // weirdly, repacking this into AoS is a 10% speed loss, instead of a win + stbi__uint16 code[256]; + stbi_uc values[256]; + stbi_uc size[257]; + unsigned int maxcode[18]; + int delta[17]; // old 'firstsymbol' - old 'firstcode' +} stbi__huffman; + +typedef struct +{ + stbi__context *s; + stbi__huffman huff_dc[4]; + stbi__huffman huff_ac[4]; + stbi__uint16 dequant[4][64]; + stbi__int16 fast_ac[4][1 << FAST_BITS]; + +// sizes for components, interleaved MCUs + int img_h_max, img_v_max; + int img_mcu_x, img_mcu_y; + int img_mcu_w, img_mcu_h; + +// definition of jpeg image component + struct + { + int id; + int h,v; + int tq; + int hd,ha; + int dc_pred; + + int x,y,w2,h2; + stbi_uc *data; + void *raw_data, *raw_coeff; + stbi_uc *linebuf; + short *coeff; // progressive only + int coeff_w, coeff_h; // number of 8x8 coefficient blocks + } img_comp[4]; + + stbi__uint32 code_buffer; // jpeg entropy-coded buffer + int code_bits; // number of valid bits + unsigned char marker; // marker seen while filling entropy buffer + int nomore; // flag if we saw a marker so must stop + + int progressive; + int spec_start; + int spec_end; + int succ_high; + int succ_low; + int eob_run; + int jfif; + int app14_color_transform; // Adobe APP14 tag + int rgb; + + int scan_n, order[4]; + int restart_interval, todo; + +// kernels + void (*idct_block_kernel)(stbi_uc *out, int out_stride, short data[64]); + void (*YCbCr_to_RGB_kernel)(stbi_uc *out, const stbi_uc *y, const stbi_uc *pcb, const stbi_uc *pcr, int count, int step); + stbi_uc *(*resample_row_hv_2_kernel)(stbi_uc *out, stbi_uc *in_near, stbi_uc *in_far, int w, int hs); +} stbi__jpeg; + +static int stbi__build_huffman(stbi__huffman *h, int *count) +{ + int i,j,k=0; + unsigned int code; + // build size list for each symbol (from JPEG spec) + for (i=0; i < 16; ++i) + for (j=0; j < count[i]; ++j) + h->size[k++] = (stbi_uc) (i+1); + h->size[k] = 0; + + // compute actual symbols (from jpeg spec) + code = 0; + k = 0; + for(j=1; j <= 16; ++j) { + // compute delta to add to code to compute symbol id + h->delta[j] = k - code; + if (h->size[k] == j) { + while (h->size[k] == j) + h->code[k++] = (stbi__uint16) (code++); + if (code-1 >= (1u << j)) return stbi__err("bad code lengths","Corrupt JPEG"); + } + // compute largest code + 1 for this size, preshifted as needed later + h->maxcode[j] = code << (16-j); + code <<= 1; + } + h->maxcode[j] = 0xffffffff; + + // build non-spec acceleration table; 255 is flag for not-accelerated + memset(h->fast, 255, 1 << FAST_BITS); + for (i=0; i < k; ++i) { + int s = h->size[i]; + if (s <= FAST_BITS) { + int c = h->code[i] << (FAST_BITS-s); + int m = 1 << (FAST_BITS-s); + for (j=0; j < m; ++j) { + h->fast[c+j] = (stbi_uc) i; + } + } + } + return 1; +} + +// build a table that decodes both magnitude and value of small ACs in +// one go. +static void stbi__build_fast_ac(stbi__int16 *fast_ac, stbi__huffman *h) +{ + int i; + for (i=0; i < (1 << FAST_BITS); ++i) { + stbi_uc fast = h->fast[i]; + fast_ac[i] = 0; + if (fast < 255) { + int rs = h->values[fast]; + int run = (rs >> 4) & 15; + int magbits = rs & 15; + int len = h->size[fast]; + + if (magbits && len + magbits <= FAST_BITS) { + // magnitude code followed by receive_extend code + int k = ((i << len) & ((1 << FAST_BITS) - 1)) >> (FAST_BITS - magbits); + int m = 1 << (magbits - 1); + if (k < m) k += (~0U << magbits) + 1; + // if the result is small enough, we can fit it in fast_ac table + if (k >= -128 && k <= 127) + fast_ac[i] = (stbi__int16) ((k * 256) + (run * 16) + (len + magbits)); + } + } + } +} + +static void stbi__grow_buffer_unsafe(stbi__jpeg *j) +{ + do { + unsigned int b = j->nomore ? 0 : stbi__get8(j->s); + if (b == 0xff) { + int c = stbi__get8(j->s); + while (c == 0xff) c = stbi__get8(j->s); // consume fill bytes + if (c != 0) { + j->marker = (unsigned char) c; + j->nomore = 1; + return; + } + } + j->code_buffer |= b << (24 - j->code_bits); + j->code_bits += 8; + } while (j->code_bits <= 24); +} + +// (1 << n) - 1 +static const stbi__uint32 stbi__bmask[17]={0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535}; + +// decode a jpeg huffman value from the bitstream +stbi_inline static int stbi__jpeg_huff_decode(stbi__jpeg *j, stbi__huffman *h) +{ + unsigned int temp; + int c,k; + + if (j->code_bits < 16) stbi__grow_buffer_unsafe(j); + + // look at the top FAST_BITS and determine what symbol ID it is, + // if the code is <= FAST_BITS + c = (j->code_buffer >> (32 - FAST_BITS)) & ((1 << FAST_BITS)-1); + k = h->fast[c]; + if (k < 255) { + int s = h->size[k]; + if (s > j->code_bits) + return -1; + j->code_buffer <<= s; + j->code_bits -= s; + return h->values[k]; + } + + // naive test is to shift the code_buffer down so k bits are + // valid, then test against maxcode. To speed this up, we've + // preshifted maxcode left so that it has (16-k) 0s at the + // end; in other words, regardless of the number of bits, it + // wants to be compared against something shifted to have 16; + // that way we don't need to shift inside the loop. + temp = j->code_buffer >> 16; + for (k=FAST_BITS+1 ; ; ++k) + if (temp < h->maxcode[k]) + break; + if (k == 17) { + // error! code not found + j->code_bits -= 16; + return -1; + } + + if (k > j->code_bits) + return -1; + + // convert the huffman code to the symbol id + c = ((j->code_buffer >> (32 - k)) & stbi__bmask[k]) + h->delta[k]; + STBI_ASSERT((((j->code_buffer) >> (32 - h->size[c])) & stbi__bmask[h->size[c]]) == h->code[c]); + + // convert the id to a symbol + j->code_bits -= k; + j->code_buffer <<= k; + return h->values[c]; +} + +// bias[n] = (-1<code_bits < n) stbi__grow_buffer_unsafe(j); + + sgn = (stbi__int32)j->code_buffer >> 31; // sign bit is always in MSB + k = stbi_lrot(j->code_buffer, n); + if (n < 0 || n >= (int) (sizeof(stbi__bmask)/sizeof(*stbi__bmask))) return 0; + j->code_buffer = k & ~stbi__bmask[n]; + k &= stbi__bmask[n]; + j->code_bits -= n; + return k + (stbi__jbias[n] & ~sgn); +} + +// get some unsigned bits +stbi_inline static int stbi__jpeg_get_bits(stbi__jpeg *j, int n) +{ + unsigned int k; + if (j->code_bits < n) stbi__grow_buffer_unsafe(j); + k = stbi_lrot(j->code_buffer, n); + j->code_buffer = k & ~stbi__bmask[n]; + k &= stbi__bmask[n]; + j->code_bits -= n; + return k; +} + +stbi_inline static int stbi__jpeg_get_bit(stbi__jpeg *j) +{ + unsigned int k; + if (j->code_bits < 1) stbi__grow_buffer_unsafe(j); + k = j->code_buffer; + j->code_buffer <<= 1; + --j->code_bits; + return k & 0x80000000; +} + +// given a value that's at position X in the zigzag stream, +// where does it appear in the 8x8 matrix coded as row-major? +static const stbi_uc stbi__jpeg_dezigzag[64+15] = +{ + 0, 1, 8, 16, 9, 2, 3, 10, + 17, 24, 32, 25, 18, 11, 4, 5, + 12, 19, 26, 33, 40, 48, 41, 34, + 27, 20, 13, 6, 7, 14, 21, 28, + 35, 42, 49, 56, 57, 50, 43, 36, + 29, 22, 15, 23, 30, 37, 44, 51, + 58, 59, 52, 45, 38, 31, 39, 46, + 53, 60, 61, 54, 47, 55, 62, 63, + // let corrupt input sample past end + 63, 63, 63, 63, 63, 63, 63, 63, + 63, 63, 63, 63, 63, 63, 63 +}; + +// decode one 64-entry block-- +static int stbi__jpeg_decode_block(stbi__jpeg *j, short data[64], stbi__huffman *hdc, stbi__huffman *hac, stbi__int16 *fac, int b, stbi__uint16 *dequant) +{ + int diff,dc,k; + int t; + + if (j->code_bits < 16) stbi__grow_buffer_unsafe(j); + t = stbi__jpeg_huff_decode(j, hdc); + if (t < 0) return stbi__err("bad huffman code","Corrupt JPEG"); + + // 0 all the ac values now so we can do it 32-bits at a time + memset(data,0,64*sizeof(data[0])); + + diff = t ? stbi__extend_receive(j, t) : 0; + dc = j->img_comp[b].dc_pred + diff; + j->img_comp[b].dc_pred = dc; + data[0] = (short) (dc * dequant[0]); + + // decode AC components, see JPEG spec + k = 1; + do { + unsigned int zig; + int c,r,s; + if (j->code_bits < 16) stbi__grow_buffer_unsafe(j); + c = (j->code_buffer >> (32 - FAST_BITS)) & ((1 << FAST_BITS)-1); + r = fac[c]; + if (r) { // fast-AC path + k += (r >> 4) & 15; // run + s = r & 15; // combined length + j->code_buffer <<= s; + j->code_bits -= s; + // decode into unzigzag'd location + zig = stbi__jpeg_dezigzag[k++]; + data[zig] = (short) ((r >> 8) * dequant[zig]); + } else { + int rs = stbi__jpeg_huff_decode(j, hac); + if (rs < 0) return stbi__err("bad huffman code","Corrupt JPEG"); + s = rs & 15; + r = rs >> 4; + if (s == 0) { + if (rs != 0xf0) break; // end block + k += 16; + } else { + k += r; + // decode into unzigzag'd location + zig = stbi__jpeg_dezigzag[k++]; + data[zig] = (short) (stbi__extend_receive(j,s) * dequant[zig]); + } + } + } while (k < 64); + return 1; +} + +static int stbi__jpeg_decode_block_prog_dc(stbi__jpeg *j, short data[64], stbi__huffman *hdc, int b) +{ + int diff,dc; + int t; + if (j->spec_end != 0) return stbi__err("can't merge dc and ac", "Corrupt JPEG"); + + if (j->code_bits < 16) stbi__grow_buffer_unsafe(j); + + if (j->succ_high == 0) { + // first scan for DC coefficient, must be first + memset(data,0,64*sizeof(data[0])); // 0 all the ac values now + t = stbi__jpeg_huff_decode(j, hdc); + if (t == -1) return stbi__err("can't merge dc and ac", "Corrupt JPEG"); + diff = t ? stbi__extend_receive(j, t) : 0; + + dc = j->img_comp[b].dc_pred + diff; + j->img_comp[b].dc_pred = dc; + data[0] = (short) (dc << j->succ_low); + } else { + // refinement scan for DC coefficient + if (stbi__jpeg_get_bit(j)) + data[0] += (short) (1 << j->succ_low); + } + return 1; +} + +// @OPTIMIZE: store non-zigzagged during the decode passes, +// and only de-zigzag when dequantizing +static int stbi__jpeg_decode_block_prog_ac(stbi__jpeg *j, short data[64], stbi__huffman *hac, stbi__int16 *fac) +{ + int k; + if (j->spec_start == 0) return stbi__err("can't merge dc and ac", "Corrupt JPEG"); + + if (j->succ_high == 0) { + int shift = j->succ_low; + + if (j->eob_run) { + --j->eob_run; + return 1; + } + + k = j->spec_start; + do { + unsigned int zig; + int c,r,s; + if (j->code_bits < 16) stbi__grow_buffer_unsafe(j); + c = (j->code_buffer >> (32 - FAST_BITS)) & ((1 << FAST_BITS)-1); + r = fac[c]; + if (r) { // fast-AC path + k += (r >> 4) & 15; // run + s = r & 15; // combined length + j->code_buffer <<= s; + j->code_bits -= s; + zig = stbi__jpeg_dezigzag[k++]; + data[zig] = (short) ((r >> 8) << shift); + } else { + int rs = stbi__jpeg_huff_decode(j, hac); + if (rs < 0) return stbi__err("bad huffman code","Corrupt JPEG"); + s = rs & 15; + r = rs >> 4; + if (s == 0) { + if (r < 15) { + j->eob_run = (1 << r); + if (r) + j->eob_run += stbi__jpeg_get_bits(j, r); + --j->eob_run; + break; + } + k += 16; + } else { + k += r; + zig = stbi__jpeg_dezigzag[k++]; + data[zig] = (short) (stbi__extend_receive(j,s) << shift); + } + } + } while (k <= j->spec_end); + } else { + // refinement scan for these AC coefficients + + short bit = (short) (1 << j->succ_low); + + if (j->eob_run) { + --j->eob_run; + for (k = j->spec_start; k <= j->spec_end; ++k) { + short *p = &data[stbi__jpeg_dezigzag[k]]; + if (*p != 0) + if (stbi__jpeg_get_bit(j)) + if ((*p & bit)==0) { + if (*p > 0) + *p += bit; + else + *p -= bit; + } + } + } else { + k = j->spec_start; + do { + int r,s; + int rs = stbi__jpeg_huff_decode(j, hac); // @OPTIMIZE see if we can use the fast path here, advance-by-r is so slow, eh + if (rs < 0) return stbi__err("bad huffman code","Corrupt JPEG"); + s = rs & 15; + r = rs >> 4; + if (s == 0) { + if (r < 15) { + j->eob_run = (1 << r) - 1; + if (r) + j->eob_run += stbi__jpeg_get_bits(j, r); + r = 64; // force end of block + } else { + // r=15 s=0 should write 16 0s, so we just do + // a run of 15 0s and then write s (which is 0), + // so we don't have to do anything special here + } + } else { + if (s != 1) return stbi__err("bad huffman code", "Corrupt JPEG"); + // sign bit + if (stbi__jpeg_get_bit(j)) + s = bit; + else + s = -bit; + } + + // advance by r + while (k <= j->spec_end) { + short *p = &data[stbi__jpeg_dezigzag[k++]]; + if (*p != 0) { + if (stbi__jpeg_get_bit(j)) + if ((*p & bit)==0) { + if (*p > 0) + *p += bit; + else + *p -= bit; + } + } else { + if (r == 0) { + *p = (short) s; + break; + } + --r; + } + } + } while (k <= j->spec_end); + } + } + return 1; +} + +// take a -128..127 value and stbi__clamp it and convert to 0..255 +stbi_inline static stbi_uc stbi__clamp(int x) +{ + // trick to use a single test to catch both cases + if ((unsigned int) x > 255) { + if (x < 0) return 0; + if (x > 255) return 255; + } + return (stbi_uc) x; +} + +#define stbi__f2f(x) ((int) (((x) * 4096 + 0.5))) +#define stbi__fsh(x) ((x) * 4096) + +// derived from jidctint -- DCT_ISLOW +#define STBI__IDCT_1D(s0,s1,s2,s3,s4,s5,s6,s7) \ + int t0,t1,t2,t3,p1,p2,p3,p4,p5,x0,x1,x2,x3; \ + p2 = s2; \ + p3 = s6; \ + p1 = (p2+p3) * stbi__f2f(0.5411961f); \ + t2 = p1 + p3*stbi__f2f(-1.847759065f); \ + t3 = p1 + p2*stbi__f2f( 0.765366865f); \ + p2 = s0; \ + p3 = s4; \ + t0 = stbi__fsh(p2+p3); \ + t1 = stbi__fsh(p2-p3); \ + x0 = t0+t3; \ + x3 = t0-t3; \ + x1 = t1+t2; \ + x2 = t1-t2; \ + t0 = s7; \ + t1 = s5; \ + t2 = s3; \ + t3 = s1; \ + p3 = t0+t2; \ + p4 = t1+t3; \ + p1 = t0+t3; \ + p2 = t1+t2; \ + p5 = (p3+p4)*stbi__f2f( 1.175875602f); \ + t0 = t0*stbi__f2f( 0.298631336f); \ + t1 = t1*stbi__f2f( 2.053119869f); \ + t2 = t2*stbi__f2f( 3.072711026f); \ + t3 = t3*stbi__f2f( 1.501321110f); \ + p1 = p5 + p1*stbi__f2f(-0.899976223f); \ + p2 = p5 + p2*stbi__f2f(-2.562915447f); \ + p3 = p3*stbi__f2f(-1.961570560f); \ + p4 = p4*stbi__f2f(-0.390180644f); \ + t3 += p1+p4; \ + t2 += p2+p3; \ + t1 += p2+p4; \ + t0 += p1+p3; + +static void stbi__idct_block(stbi_uc *out, int out_stride, short data[64]) +{ + int i,val[64],*v=val; + stbi_uc *o; + short *d = data; + + // columns + for (i=0; i < 8; ++i,++d, ++v) { + // if all zeroes, shortcut -- this avoids dequantizing 0s and IDCTing + if (d[ 8]==0 && d[16]==0 && d[24]==0 && d[32]==0 + && d[40]==0 && d[48]==0 && d[56]==0) { + // no shortcut 0 seconds + // (1|2|3|4|5|6|7)==0 0 seconds + // all separate -0.047 seconds + // 1 && 2|3 && 4|5 && 6|7: -0.047 seconds + int dcterm = d[0]*4; + v[0] = v[8] = v[16] = v[24] = v[32] = v[40] = v[48] = v[56] = dcterm; + } else { + STBI__IDCT_1D(d[ 0],d[ 8],d[16],d[24],d[32],d[40],d[48],d[56]) + // constants scaled things up by 1<<12; let's bring them back + // down, but keep 2 extra bits of precision + x0 += 512; x1 += 512; x2 += 512; x3 += 512; + v[ 0] = (x0+t3) >> 10; + v[56] = (x0-t3) >> 10; + v[ 8] = (x1+t2) >> 10; + v[48] = (x1-t2) >> 10; + v[16] = (x2+t1) >> 10; + v[40] = (x2-t1) >> 10; + v[24] = (x3+t0) >> 10; + v[32] = (x3-t0) >> 10; + } + } + + for (i=0, v=val, o=out; i < 8; ++i,v+=8,o+=out_stride) { + // no fast case since the first 1D IDCT spread components out + STBI__IDCT_1D(v[0],v[1],v[2],v[3],v[4],v[5],v[6],v[7]) + // constants scaled things up by 1<<12, plus we had 1<<2 from first + // loop, plus horizontal and vertical each scale by sqrt(8) so together + // we've got an extra 1<<3, so 1<<17 total we need to remove. + // so we want to round that, which means adding 0.5 * 1<<17, + // aka 65536. Also, we'll end up with -128 to 127 that we want + // to encode as 0..255 by adding 128, so we'll add that before the shift + x0 += 65536 + (128<<17); + x1 += 65536 + (128<<17); + x2 += 65536 + (128<<17); + x3 += 65536 + (128<<17); + // tried computing the shifts into temps, or'ing the temps to see + // if any were out of range, but that was slower + o[0] = stbi__clamp((x0+t3) >> 17); + o[7] = stbi__clamp((x0-t3) >> 17); + o[1] = stbi__clamp((x1+t2) >> 17); + o[6] = stbi__clamp((x1-t2) >> 17); + o[2] = stbi__clamp((x2+t1) >> 17); + o[5] = stbi__clamp((x2-t1) >> 17); + o[3] = stbi__clamp((x3+t0) >> 17); + o[4] = stbi__clamp((x3-t0) >> 17); + } +} + +#ifdef STBI_SSE2 +// sse2 integer IDCT. not the fastest possible implementation but it +// produces bit-identical results to the generic C version so it's +// fully "transparent". +static void stbi__idct_simd(stbi_uc *out, int out_stride, short data[64]) +{ + // This is constructed to match our regular (generic) integer IDCT exactly. + __m128i row0, row1, row2, row3, row4, row5, row6, row7; + __m128i tmp; + + // dot product constant: even elems=x, odd elems=y + #define dct_const(x,y) _mm_setr_epi16((x),(y),(x),(y),(x),(y),(x),(y)) + + // out(0) = c0[even]*x + c0[odd]*y (c0, x, y 16-bit, out 32-bit) + // out(1) = c1[even]*x + c1[odd]*y + #define dct_rot(out0,out1, x,y,c0,c1) \ + __m128i c0##lo = _mm_unpacklo_epi16((x),(y)); \ + __m128i c0##hi = _mm_unpackhi_epi16((x),(y)); \ + __m128i out0##_l = _mm_madd_epi16(c0##lo, c0); \ + __m128i out0##_h = _mm_madd_epi16(c0##hi, c0); \ + __m128i out1##_l = _mm_madd_epi16(c0##lo, c1); \ + __m128i out1##_h = _mm_madd_epi16(c0##hi, c1) + + // out = in << 12 (in 16-bit, out 32-bit) + #define dct_widen(out, in) \ + __m128i out##_l = _mm_srai_epi32(_mm_unpacklo_epi16(_mm_setzero_si128(), (in)), 4); \ + __m128i out##_h = _mm_srai_epi32(_mm_unpackhi_epi16(_mm_setzero_si128(), (in)), 4) + + // wide add + #define dct_wadd(out, a, b) \ + __m128i out##_l = _mm_add_epi32(a##_l, b##_l); \ + __m128i out##_h = _mm_add_epi32(a##_h, b##_h) + + // wide sub + #define dct_wsub(out, a, b) \ + __m128i out##_l = _mm_sub_epi32(a##_l, b##_l); \ + __m128i out##_h = _mm_sub_epi32(a##_h, b##_h) + + // butterfly a/b, add bias, then shift by "s" and pack + #define dct_bfly32o(out0, out1, a,b,bias,s) \ + { \ + __m128i abiased_l = _mm_add_epi32(a##_l, bias); \ + __m128i abiased_h = _mm_add_epi32(a##_h, bias); \ + dct_wadd(sum, abiased, b); \ + dct_wsub(dif, abiased, b); \ + out0 = _mm_packs_epi32(_mm_srai_epi32(sum_l, s), _mm_srai_epi32(sum_h, s)); \ + out1 = _mm_packs_epi32(_mm_srai_epi32(dif_l, s), _mm_srai_epi32(dif_h, s)); \ + } + + // 8-bit interleave step (for transposes) + #define dct_interleave8(a, b) \ + tmp = a; \ + a = _mm_unpacklo_epi8(a, b); \ + b = _mm_unpackhi_epi8(tmp, b) + + // 16-bit interleave step (for transposes) + #define dct_interleave16(a, b) \ + tmp = a; \ + a = _mm_unpacklo_epi16(a, b); \ + b = _mm_unpackhi_epi16(tmp, b) + + #define dct_pass(bias,shift) \ + { \ + /* even part */ \ + dct_rot(t2e,t3e, row2,row6, rot0_0,rot0_1); \ + __m128i sum04 = _mm_add_epi16(row0, row4); \ + __m128i dif04 = _mm_sub_epi16(row0, row4); \ + dct_widen(t0e, sum04); \ + dct_widen(t1e, dif04); \ + dct_wadd(x0, t0e, t3e); \ + dct_wsub(x3, t0e, t3e); \ + dct_wadd(x1, t1e, t2e); \ + dct_wsub(x2, t1e, t2e); \ + /* odd part */ \ + dct_rot(y0o,y2o, row7,row3, rot2_0,rot2_1); \ + dct_rot(y1o,y3o, row5,row1, rot3_0,rot3_1); \ + __m128i sum17 = _mm_add_epi16(row1, row7); \ + __m128i sum35 = _mm_add_epi16(row3, row5); \ + dct_rot(y4o,y5o, sum17,sum35, rot1_0,rot1_1); \ + dct_wadd(x4, y0o, y4o); \ + dct_wadd(x5, y1o, y5o); \ + dct_wadd(x6, y2o, y5o); \ + dct_wadd(x7, y3o, y4o); \ + dct_bfly32o(row0,row7, x0,x7,bias,shift); \ + dct_bfly32o(row1,row6, x1,x6,bias,shift); \ + dct_bfly32o(row2,row5, x2,x5,bias,shift); \ + dct_bfly32o(row3,row4, x3,x4,bias,shift); \ + } + + __m128i rot0_0 = dct_const(stbi__f2f(0.5411961f), stbi__f2f(0.5411961f) + stbi__f2f(-1.847759065f)); + __m128i rot0_1 = dct_const(stbi__f2f(0.5411961f) + stbi__f2f( 0.765366865f), stbi__f2f(0.5411961f)); + __m128i rot1_0 = dct_const(stbi__f2f(1.175875602f) + stbi__f2f(-0.899976223f), stbi__f2f(1.175875602f)); + __m128i rot1_1 = dct_const(stbi__f2f(1.175875602f), stbi__f2f(1.175875602f) + stbi__f2f(-2.562915447f)); + __m128i rot2_0 = dct_const(stbi__f2f(-1.961570560f) + stbi__f2f( 0.298631336f), stbi__f2f(-1.961570560f)); + __m128i rot2_1 = dct_const(stbi__f2f(-1.961570560f), stbi__f2f(-1.961570560f) + stbi__f2f( 3.072711026f)); + __m128i rot3_0 = dct_const(stbi__f2f(-0.390180644f) + stbi__f2f( 2.053119869f), stbi__f2f(-0.390180644f)); + __m128i rot3_1 = dct_const(stbi__f2f(-0.390180644f), stbi__f2f(-0.390180644f) + stbi__f2f( 1.501321110f)); + + // rounding biases in column/row passes, see stbi__idct_block for explanation. + __m128i bias_0 = _mm_set1_epi32(512); + __m128i bias_1 = _mm_set1_epi32(65536 + (128<<17)); + + // load + row0 = _mm_load_si128((const __m128i *) (data + 0*8)); + row1 = _mm_load_si128((const __m128i *) (data + 1*8)); + row2 = _mm_load_si128((const __m128i *) (data + 2*8)); + row3 = _mm_load_si128((const __m128i *) (data + 3*8)); + row4 = _mm_load_si128((const __m128i *) (data + 4*8)); + row5 = _mm_load_si128((const __m128i *) (data + 5*8)); + row6 = _mm_load_si128((const __m128i *) (data + 6*8)); + row7 = _mm_load_si128((const __m128i *) (data + 7*8)); + + // column pass + dct_pass(bias_0, 10); + + { + // 16bit 8x8 transpose pass 1 + dct_interleave16(row0, row4); + dct_interleave16(row1, row5); + dct_interleave16(row2, row6); + dct_interleave16(row3, row7); + + // transpose pass 2 + dct_interleave16(row0, row2); + dct_interleave16(row1, row3); + dct_interleave16(row4, row6); + dct_interleave16(row5, row7); + + // transpose pass 3 + dct_interleave16(row0, row1); + dct_interleave16(row2, row3); + dct_interleave16(row4, row5); + dct_interleave16(row6, row7); + } + + // row pass + dct_pass(bias_1, 17); + + { + // pack + __m128i p0 = _mm_packus_epi16(row0, row1); // a0a1a2a3...a7b0b1b2b3...b7 + __m128i p1 = _mm_packus_epi16(row2, row3); + __m128i p2 = _mm_packus_epi16(row4, row5); + __m128i p3 = _mm_packus_epi16(row6, row7); + + // 8bit 8x8 transpose pass 1 + dct_interleave8(p0, p2); // a0e0a1e1... + dct_interleave8(p1, p3); // c0g0c1g1... + + // transpose pass 2 + dct_interleave8(p0, p1); // a0c0e0g0... + dct_interleave8(p2, p3); // b0d0f0h0... + + // transpose pass 3 + dct_interleave8(p0, p2); // a0b0c0d0... + dct_interleave8(p1, p3); // a4b4c4d4... + + // store + _mm_storel_epi64((__m128i *) out, p0); out += out_stride; + _mm_storel_epi64((__m128i *) out, _mm_shuffle_epi32(p0, 0x4e)); out += out_stride; + _mm_storel_epi64((__m128i *) out, p2); out += out_stride; + _mm_storel_epi64((__m128i *) out, _mm_shuffle_epi32(p2, 0x4e)); out += out_stride; + _mm_storel_epi64((__m128i *) out, p1); out += out_stride; + _mm_storel_epi64((__m128i *) out, _mm_shuffle_epi32(p1, 0x4e)); out += out_stride; + _mm_storel_epi64((__m128i *) out, p3); out += out_stride; + _mm_storel_epi64((__m128i *) out, _mm_shuffle_epi32(p3, 0x4e)); + } + +#undef dct_const +#undef dct_rot +#undef dct_widen +#undef dct_wadd +#undef dct_wsub +#undef dct_bfly32o +#undef dct_interleave8 +#undef dct_interleave16 +#undef dct_pass +} + +#endif // STBI_SSE2 + +#ifdef STBI_NEON + +// NEON integer IDCT. should produce bit-identical +// results to the generic C version. +static void stbi__idct_simd(stbi_uc *out, int out_stride, short data[64]) +{ + int16x8_t row0, row1, row2, row3, row4, row5, row6, row7; + + int16x4_t rot0_0 = vdup_n_s16(stbi__f2f(0.5411961f)); + int16x4_t rot0_1 = vdup_n_s16(stbi__f2f(-1.847759065f)); + int16x4_t rot0_2 = vdup_n_s16(stbi__f2f( 0.765366865f)); + int16x4_t rot1_0 = vdup_n_s16(stbi__f2f( 1.175875602f)); + int16x4_t rot1_1 = vdup_n_s16(stbi__f2f(-0.899976223f)); + int16x4_t rot1_2 = vdup_n_s16(stbi__f2f(-2.562915447f)); + int16x4_t rot2_0 = vdup_n_s16(stbi__f2f(-1.961570560f)); + int16x4_t rot2_1 = vdup_n_s16(stbi__f2f(-0.390180644f)); + int16x4_t rot3_0 = vdup_n_s16(stbi__f2f( 0.298631336f)); + int16x4_t rot3_1 = vdup_n_s16(stbi__f2f( 2.053119869f)); + int16x4_t rot3_2 = vdup_n_s16(stbi__f2f( 3.072711026f)); + int16x4_t rot3_3 = vdup_n_s16(stbi__f2f( 1.501321110f)); + +#define dct_long_mul(out, inq, coeff) \ + int32x4_t out##_l = vmull_s16(vget_low_s16(inq), coeff); \ + int32x4_t out##_h = vmull_s16(vget_high_s16(inq), coeff) + +#define dct_long_mac(out, acc, inq, coeff) \ + int32x4_t out##_l = vmlal_s16(acc##_l, vget_low_s16(inq), coeff); \ + int32x4_t out##_h = vmlal_s16(acc##_h, vget_high_s16(inq), coeff) + +#define dct_widen(out, inq) \ + int32x4_t out##_l = vshll_n_s16(vget_low_s16(inq), 12); \ + int32x4_t out##_h = vshll_n_s16(vget_high_s16(inq), 12) + +// wide add +#define dct_wadd(out, a, b) \ + int32x4_t out##_l = vaddq_s32(a##_l, b##_l); \ + int32x4_t out##_h = vaddq_s32(a##_h, b##_h) + +// wide sub +#define dct_wsub(out, a, b) \ + int32x4_t out##_l = vsubq_s32(a##_l, b##_l); \ + int32x4_t out##_h = vsubq_s32(a##_h, b##_h) + +// butterfly a/b, then shift using "shiftop" by "s" and pack +#define dct_bfly32o(out0,out1, a,b,shiftop,s) \ + { \ + dct_wadd(sum, a, b); \ + dct_wsub(dif, a, b); \ + out0 = vcombine_s16(shiftop(sum_l, s), shiftop(sum_h, s)); \ + out1 = vcombine_s16(shiftop(dif_l, s), shiftop(dif_h, s)); \ + } + +#define dct_pass(shiftop, shift) \ + { \ + /* even part */ \ + int16x8_t sum26 = vaddq_s16(row2, row6); \ + dct_long_mul(p1e, sum26, rot0_0); \ + dct_long_mac(t2e, p1e, row6, rot0_1); \ + dct_long_mac(t3e, p1e, row2, rot0_2); \ + int16x8_t sum04 = vaddq_s16(row0, row4); \ + int16x8_t dif04 = vsubq_s16(row0, row4); \ + dct_widen(t0e, sum04); \ + dct_widen(t1e, dif04); \ + dct_wadd(x0, t0e, t3e); \ + dct_wsub(x3, t0e, t3e); \ + dct_wadd(x1, t1e, t2e); \ + dct_wsub(x2, t1e, t2e); \ + /* odd part */ \ + int16x8_t sum15 = vaddq_s16(row1, row5); \ + int16x8_t sum17 = vaddq_s16(row1, row7); \ + int16x8_t sum35 = vaddq_s16(row3, row5); \ + int16x8_t sum37 = vaddq_s16(row3, row7); \ + int16x8_t sumodd = vaddq_s16(sum17, sum35); \ + dct_long_mul(p5o, sumodd, rot1_0); \ + dct_long_mac(p1o, p5o, sum17, rot1_1); \ + dct_long_mac(p2o, p5o, sum35, rot1_2); \ + dct_long_mul(p3o, sum37, rot2_0); \ + dct_long_mul(p4o, sum15, rot2_1); \ + dct_wadd(sump13o, p1o, p3o); \ + dct_wadd(sump24o, p2o, p4o); \ + dct_wadd(sump23o, p2o, p3o); \ + dct_wadd(sump14o, p1o, p4o); \ + dct_long_mac(x4, sump13o, row7, rot3_0); \ + dct_long_mac(x5, sump24o, row5, rot3_1); \ + dct_long_mac(x6, sump23o, row3, rot3_2); \ + dct_long_mac(x7, sump14o, row1, rot3_3); \ + dct_bfly32o(row0,row7, x0,x7,shiftop,shift); \ + dct_bfly32o(row1,row6, x1,x6,shiftop,shift); \ + dct_bfly32o(row2,row5, x2,x5,shiftop,shift); \ + dct_bfly32o(row3,row4, x3,x4,shiftop,shift); \ + } + + // load + row0 = vld1q_s16(data + 0*8); + row1 = vld1q_s16(data + 1*8); + row2 = vld1q_s16(data + 2*8); + row3 = vld1q_s16(data + 3*8); + row4 = vld1q_s16(data + 4*8); + row5 = vld1q_s16(data + 5*8); + row6 = vld1q_s16(data + 6*8); + row7 = vld1q_s16(data + 7*8); + + // add DC bias + row0 = vaddq_s16(row0, vsetq_lane_s16(1024, vdupq_n_s16(0), 0)); + + // column pass + dct_pass(vrshrn_n_s32, 10); + + // 16bit 8x8 transpose + { +// these three map to a single VTRN.16, VTRN.32, and VSWP, respectively. +// whether compilers actually get this is another story, sadly. +#define dct_trn16(x, y) { int16x8x2_t t = vtrnq_s16(x, y); x = t.val[0]; y = t.val[1]; } +#define dct_trn32(x, y) { int32x4x2_t t = vtrnq_s32(vreinterpretq_s32_s16(x), vreinterpretq_s32_s16(y)); x = vreinterpretq_s16_s32(t.val[0]); y = vreinterpretq_s16_s32(t.val[1]); } +#define dct_trn64(x, y) { int16x8_t x0 = x; int16x8_t y0 = y; x = vcombine_s16(vget_low_s16(x0), vget_low_s16(y0)); y = vcombine_s16(vget_high_s16(x0), vget_high_s16(y0)); } + + // pass 1 + dct_trn16(row0, row1); // a0b0a2b2a4b4a6b6 + dct_trn16(row2, row3); + dct_trn16(row4, row5); + dct_trn16(row6, row7); + + // pass 2 + dct_trn32(row0, row2); // a0b0c0d0a4b4c4d4 + dct_trn32(row1, row3); + dct_trn32(row4, row6); + dct_trn32(row5, row7); + + // pass 3 + dct_trn64(row0, row4); // a0b0c0d0e0f0g0h0 + dct_trn64(row1, row5); + dct_trn64(row2, row6); + dct_trn64(row3, row7); + +#undef dct_trn16 +#undef dct_trn32 +#undef dct_trn64 + } + + // row pass + // vrshrn_n_s32 only supports shifts up to 16, we need + // 17. so do a non-rounding shift of 16 first then follow + // up with a rounding shift by 1. + dct_pass(vshrn_n_s32, 16); + + { + // pack and round + uint8x8_t p0 = vqrshrun_n_s16(row0, 1); + uint8x8_t p1 = vqrshrun_n_s16(row1, 1); + uint8x8_t p2 = vqrshrun_n_s16(row2, 1); + uint8x8_t p3 = vqrshrun_n_s16(row3, 1); + uint8x8_t p4 = vqrshrun_n_s16(row4, 1); + uint8x8_t p5 = vqrshrun_n_s16(row5, 1); + uint8x8_t p6 = vqrshrun_n_s16(row6, 1); + uint8x8_t p7 = vqrshrun_n_s16(row7, 1); + + // again, these can translate into one instruction, but often don't. +#define dct_trn8_8(x, y) { uint8x8x2_t t = vtrn_u8(x, y); x = t.val[0]; y = t.val[1]; } +#define dct_trn8_16(x, y) { uint16x4x2_t t = vtrn_u16(vreinterpret_u16_u8(x), vreinterpret_u16_u8(y)); x = vreinterpret_u8_u16(t.val[0]); y = vreinterpret_u8_u16(t.val[1]); } +#define dct_trn8_32(x, y) { uint32x2x2_t t = vtrn_u32(vreinterpret_u32_u8(x), vreinterpret_u32_u8(y)); x = vreinterpret_u8_u32(t.val[0]); y = vreinterpret_u8_u32(t.val[1]); } + + // sadly can't use interleaved stores here since we only write + // 8 bytes to each scan line! + + // 8x8 8-bit transpose pass 1 + dct_trn8_8(p0, p1); + dct_trn8_8(p2, p3); + dct_trn8_8(p4, p5); + dct_trn8_8(p6, p7); + + // pass 2 + dct_trn8_16(p0, p2); + dct_trn8_16(p1, p3); + dct_trn8_16(p4, p6); + dct_trn8_16(p5, p7); + + // pass 3 + dct_trn8_32(p0, p4); + dct_trn8_32(p1, p5); + dct_trn8_32(p2, p6); + dct_trn8_32(p3, p7); + + // store + vst1_u8(out, p0); out += out_stride; + vst1_u8(out, p1); out += out_stride; + vst1_u8(out, p2); out += out_stride; + vst1_u8(out, p3); out += out_stride; + vst1_u8(out, p4); out += out_stride; + vst1_u8(out, p5); out += out_stride; + vst1_u8(out, p6); out += out_stride; + vst1_u8(out, p7); + +#undef dct_trn8_8 +#undef dct_trn8_16 +#undef dct_trn8_32 + } + +#undef dct_long_mul +#undef dct_long_mac +#undef dct_widen +#undef dct_wadd +#undef dct_wsub +#undef dct_bfly32o +#undef dct_pass +} + +#endif // STBI_NEON + +#define STBI__MARKER_none 0xff +// if there's a pending marker from the entropy stream, return that +// otherwise, fetch from the stream and get a marker. if there's no +// marker, return 0xff, which is never a valid marker value +static stbi_uc stbi__get_marker(stbi__jpeg *j) +{ + stbi_uc x; + if (j->marker != STBI__MARKER_none) { x = j->marker; j->marker = STBI__MARKER_none; return x; } + x = stbi__get8(j->s); + if (x != 0xff) return STBI__MARKER_none; + while (x == 0xff) + x = stbi__get8(j->s); // consume repeated 0xff fill bytes + return x; +} + +// in each scan, we'll have scan_n components, and the order +// of the components is specified by order[] +#define STBI__RESTART(x) ((x) >= 0xd0 && (x) <= 0xd7) + +// after a restart interval, stbi__jpeg_reset the entropy decoder and +// the dc prediction +static void stbi__jpeg_reset(stbi__jpeg *j) +{ + j->code_bits = 0; + j->code_buffer = 0; + j->nomore = 0; + j->img_comp[0].dc_pred = j->img_comp[1].dc_pred = j->img_comp[2].dc_pred = j->img_comp[3].dc_pred = 0; + j->marker = STBI__MARKER_none; + j->todo = j->restart_interval ? j->restart_interval : 0x7fffffff; + j->eob_run = 0; + // no more than 1<<31 MCUs if no restart_interal? that's plenty safe, + // since we don't even allow 1<<30 pixels +} + +static int stbi__parse_entropy_coded_data(stbi__jpeg *z) +{ + stbi__jpeg_reset(z); + if (!z->progressive) { + if (z->scan_n == 1) { + int i,j; + STBI_SIMD_ALIGN(short, data[64]); + int n = z->order[0]; + // non-interleaved data, we just need to process one block at a time, + // in trivial scanline order + // number of blocks to do just depends on how many actual "pixels" this + // component has, independent of interleaved MCU blocking and such + int w = (z->img_comp[n].x+7) >> 3; + int h = (z->img_comp[n].y+7) >> 3; + for (j=0; j < h; ++j) { + for (i=0; i < w; ++i) { + int ha = z->img_comp[n].ha; + if (!stbi__jpeg_decode_block(z, data, z->huff_dc+z->img_comp[n].hd, z->huff_ac+ha, z->fast_ac[ha], n, z->dequant[z->img_comp[n].tq])) return 0; + z->idct_block_kernel(z->img_comp[n].data+z->img_comp[n].w2*j*8+i*8, z->img_comp[n].w2, data); + // every data block is an MCU, so countdown the restart interval + if (--z->todo <= 0) { + if (z->code_bits < 24) stbi__grow_buffer_unsafe(z); + // if it's NOT a restart, then just bail, so we get corrupt data + // rather than no data + if (!STBI__RESTART(z->marker)) return 1; + stbi__jpeg_reset(z); + } + } + } + return 1; + } else { // interleaved + int i,j,k,x,y; + STBI_SIMD_ALIGN(short, data[64]); + for (j=0; j < z->img_mcu_y; ++j) { + for (i=0; i < z->img_mcu_x; ++i) { + // scan an interleaved mcu... process scan_n components in order + for (k=0; k < z->scan_n; ++k) { + int n = z->order[k]; + // scan out an mcu's worth of this component; that's just determined + // by the basic H and V specified for the component + for (y=0; y < z->img_comp[n].v; ++y) { + for (x=0; x < z->img_comp[n].h; ++x) { + int x2 = (i*z->img_comp[n].h + x)*8; + int y2 = (j*z->img_comp[n].v + y)*8; + int ha = z->img_comp[n].ha; + if (!stbi__jpeg_decode_block(z, data, z->huff_dc+z->img_comp[n].hd, z->huff_ac+ha, z->fast_ac[ha], n, z->dequant[z->img_comp[n].tq])) return 0; + z->idct_block_kernel(z->img_comp[n].data+z->img_comp[n].w2*y2+x2, z->img_comp[n].w2, data); + } + } + } + // after all interleaved components, that's an interleaved MCU, + // so now count down the restart interval + if (--z->todo <= 0) { + if (z->code_bits < 24) stbi__grow_buffer_unsafe(z); + if (!STBI__RESTART(z->marker)) return 1; + stbi__jpeg_reset(z); + } + } + } + return 1; + } + } else { + if (z->scan_n == 1) { + int i,j; + int n = z->order[0]; + // non-interleaved data, we just need to process one block at a time, + // in trivial scanline order + // number of blocks to do just depends on how many actual "pixels" this + // component has, independent of interleaved MCU blocking and such + int w = (z->img_comp[n].x+7) >> 3; + int h = (z->img_comp[n].y+7) >> 3; + for (j=0; j < h; ++j) { + for (i=0; i < w; ++i) { + short *data = z->img_comp[n].coeff + 64 * (i + j * z->img_comp[n].coeff_w); + if (z->spec_start == 0) { + if (!stbi__jpeg_decode_block_prog_dc(z, data, &z->huff_dc[z->img_comp[n].hd], n)) + return 0; + } else { + int ha = z->img_comp[n].ha; + if (!stbi__jpeg_decode_block_prog_ac(z, data, &z->huff_ac[ha], z->fast_ac[ha])) + return 0; + } + // every data block is an MCU, so countdown the restart interval + if (--z->todo <= 0) { + if (z->code_bits < 24) stbi__grow_buffer_unsafe(z); + if (!STBI__RESTART(z->marker)) return 1; + stbi__jpeg_reset(z); + } + } + } + return 1; + } else { // interleaved + int i,j,k,x,y; + for (j=0; j < z->img_mcu_y; ++j) { + for (i=0; i < z->img_mcu_x; ++i) { + // scan an interleaved mcu... process scan_n components in order + for (k=0; k < z->scan_n; ++k) { + int n = z->order[k]; + // scan out an mcu's worth of this component; that's just determined + // by the basic H and V specified for the component + for (y=0; y < z->img_comp[n].v; ++y) { + for (x=0; x < z->img_comp[n].h; ++x) { + int x2 = (i*z->img_comp[n].h + x); + int y2 = (j*z->img_comp[n].v + y); + short *data = z->img_comp[n].coeff + 64 * (x2 + y2 * z->img_comp[n].coeff_w); + if (!stbi__jpeg_decode_block_prog_dc(z, data, &z->huff_dc[z->img_comp[n].hd], n)) + return 0; + } + } + } + // after all interleaved components, that's an interleaved MCU, + // so now count down the restart interval + if (--z->todo <= 0) { + if (z->code_bits < 24) stbi__grow_buffer_unsafe(z); + if (!STBI__RESTART(z->marker)) return 1; + stbi__jpeg_reset(z); + } + } + } + return 1; + } + } +} + +static void stbi__jpeg_dequantize(short *data, stbi__uint16 *dequant) +{ + int i; + for (i=0; i < 64; ++i) + data[i] *= dequant[i]; +} + +static void stbi__jpeg_finish(stbi__jpeg *z) +{ + if (z->progressive) { + // dequantize and idct the data + int i,j,n; + for (n=0; n < z->s->img_n; ++n) { + int w = (z->img_comp[n].x+7) >> 3; + int h = (z->img_comp[n].y+7) >> 3; + for (j=0; j < h; ++j) { + for (i=0; i < w; ++i) { + short *data = z->img_comp[n].coeff + 64 * (i + j * z->img_comp[n].coeff_w); + stbi__jpeg_dequantize(data, z->dequant[z->img_comp[n].tq]); + z->idct_block_kernel(z->img_comp[n].data+z->img_comp[n].w2*j*8+i*8, z->img_comp[n].w2, data); + } + } + } + } +} + +static int stbi__process_marker(stbi__jpeg *z, int m) +{ + int L; + switch (m) { + case STBI__MARKER_none: // no marker found + return stbi__err("expected marker","Corrupt JPEG"); + + case 0xDD: // DRI - specify restart interval + if (stbi__get16be(z->s) != 4) return stbi__err("bad DRI len","Corrupt JPEG"); + z->restart_interval = stbi__get16be(z->s); + return 1; + + case 0xDB: // DQT - define quantization table + L = stbi__get16be(z->s)-2; + while (L > 0) { + int q = stbi__get8(z->s); + int p = q >> 4, sixteen = (p != 0); + int t = q & 15,i; + if (p != 0 && p != 1) return stbi__err("bad DQT type","Corrupt JPEG"); + if (t > 3) return stbi__err("bad DQT table","Corrupt JPEG"); + + for (i=0; i < 64; ++i) + z->dequant[t][stbi__jpeg_dezigzag[i]] = (stbi__uint16)(sixteen ? stbi__get16be(z->s) : stbi__get8(z->s)); + L -= (sixteen ? 129 : 65); + } + return L==0; + + case 0xC4: // DHT - define huffman table + L = stbi__get16be(z->s)-2; + while (L > 0) { + stbi_uc *v; + int sizes[16],i,n=0; + int q = stbi__get8(z->s); + int tc = q >> 4; + int th = q & 15; + if (tc > 1 || th > 3) return stbi__err("bad DHT header","Corrupt JPEG"); + for (i=0; i < 16; ++i) { + sizes[i] = stbi__get8(z->s); + n += sizes[i]; + } + L -= 17; + if (tc == 0) { + if (!stbi__build_huffman(z->huff_dc+th, sizes)) return 0; + v = z->huff_dc[th].values; + } else { + if (!stbi__build_huffman(z->huff_ac+th, sizes)) return 0; + v = z->huff_ac[th].values; + } + for (i=0; i < n; ++i) + v[i] = stbi__get8(z->s); + if (tc != 0) + stbi__build_fast_ac(z->fast_ac[th], z->huff_ac + th); + L -= n; + } + return L==0; + } + + // check for comment block or APP blocks + if ((m >= 0xE0 && m <= 0xEF) || m == 0xFE) { + L = stbi__get16be(z->s); + if (L < 2) { + if (m == 0xFE) + return stbi__err("bad COM len","Corrupt JPEG"); + else + return stbi__err("bad APP len","Corrupt JPEG"); + } + L -= 2; + + if (m == 0xE0 && L >= 5) { // JFIF APP0 segment + static const unsigned char tag[5] = {'J','F','I','F','\0'}; + int ok = 1; + int i; + for (i=0; i < 5; ++i) + if (stbi__get8(z->s) != tag[i]) + ok = 0; + L -= 5; + if (ok) + z->jfif = 1; + } else if (m == 0xEE && L >= 12) { // Adobe APP14 segment + static const unsigned char tag[6] = {'A','d','o','b','e','\0'}; + int ok = 1; + int i; + for (i=0; i < 6; ++i) + if (stbi__get8(z->s) != tag[i]) + ok = 0; + L -= 6; + if (ok) { + stbi__get8(z->s); // version + stbi__get16be(z->s); // flags0 + stbi__get16be(z->s); // flags1 + z->app14_color_transform = stbi__get8(z->s); // color transform + L -= 6; + } + } + + stbi__skip(z->s, L); + return 1; + } + + return stbi__err("unknown marker","Corrupt JPEG"); +} + +// after we see SOS +static int stbi__process_scan_header(stbi__jpeg *z) +{ + int i; + int Ls = stbi__get16be(z->s); + z->scan_n = stbi__get8(z->s); + if (z->scan_n < 1 || z->scan_n > 4 || z->scan_n > (int) z->s->img_n) return stbi__err("bad SOS component count","Corrupt JPEG"); + if (Ls != 6+2*z->scan_n) return stbi__err("bad SOS len","Corrupt JPEG"); + for (i=0; i < z->scan_n; ++i) { + int id = stbi__get8(z->s), which; + int q = stbi__get8(z->s); + for (which = 0; which < z->s->img_n; ++which) + if (z->img_comp[which].id == id) + break; + if (which == z->s->img_n) return 0; // no match + z->img_comp[which].hd = q >> 4; if (z->img_comp[which].hd > 3) return stbi__err("bad DC huff","Corrupt JPEG"); + z->img_comp[which].ha = q & 15; if (z->img_comp[which].ha > 3) return stbi__err("bad AC huff","Corrupt JPEG"); + z->order[i] = which; + } + + { + int aa; + z->spec_start = stbi__get8(z->s); + z->spec_end = stbi__get8(z->s); // should be 63, but might be 0 + aa = stbi__get8(z->s); + z->succ_high = (aa >> 4); + z->succ_low = (aa & 15); + if (z->progressive) { + if (z->spec_start > 63 || z->spec_end > 63 || z->spec_start > z->spec_end || z->succ_high > 13 || z->succ_low > 13) + return stbi__err("bad SOS", "Corrupt JPEG"); + } else { + if (z->spec_start != 0) return stbi__err("bad SOS","Corrupt JPEG"); + if (z->succ_high != 0 || z->succ_low != 0) return stbi__err("bad SOS","Corrupt JPEG"); + z->spec_end = 63; + } + } + + return 1; +} + +static int stbi__free_jpeg_components(stbi__jpeg *z, int ncomp, int why) +{ + int i; + for (i=0; i < ncomp; ++i) { + if (z->img_comp[i].raw_data) { + STBI_FREE(z->img_comp[i].raw_data); + z->img_comp[i].raw_data = NULL; + z->img_comp[i].data = NULL; + } + if (z->img_comp[i].raw_coeff) { + STBI_FREE(z->img_comp[i].raw_coeff); + z->img_comp[i].raw_coeff = 0; + z->img_comp[i].coeff = 0; + } + if (z->img_comp[i].linebuf) { + STBI_FREE(z->img_comp[i].linebuf); + z->img_comp[i].linebuf = NULL; + } + } + return why; +} + +static int stbi__process_frame_header(stbi__jpeg *z, int scan) +{ + stbi__context *s = z->s; + int Lf,p,i,q, h_max=1,v_max=1,c; + Lf = stbi__get16be(s); if (Lf < 11) return stbi__err("bad SOF len","Corrupt JPEG"); // JPEG + p = stbi__get8(s); if (p != 8) return stbi__err("only 8-bit","JPEG format not supported: 8-bit only"); // JPEG baseline + s->img_y = stbi__get16be(s); if (s->img_y == 0) return stbi__err("no header height", "JPEG format not supported: delayed height"); // Legal, but we don't handle it--but neither does IJG + s->img_x = stbi__get16be(s); if (s->img_x == 0) return stbi__err("0 width","Corrupt JPEG"); // JPEG requires + if (s->img_y > STBI_MAX_DIMENSIONS) return stbi__err("too large","Very large image (corrupt?)"); + if (s->img_x > STBI_MAX_DIMENSIONS) return stbi__err("too large","Very large image (corrupt?)"); + c = stbi__get8(s); + if (c != 3 && c != 1 && c != 4) return stbi__err("bad component count","Corrupt JPEG"); + s->img_n = c; + for (i=0; i < c; ++i) { + z->img_comp[i].data = NULL; + z->img_comp[i].linebuf = NULL; + } + + if (Lf != 8+3*s->img_n) return stbi__err("bad SOF len","Corrupt JPEG"); + + z->rgb = 0; + for (i=0; i < s->img_n; ++i) { + static const unsigned char rgb[3] = { 'R', 'G', 'B' }; + z->img_comp[i].id = stbi__get8(s); + if (s->img_n == 3 && z->img_comp[i].id == rgb[i]) + ++z->rgb; + q = stbi__get8(s); + z->img_comp[i].h = (q >> 4); if (!z->img_comp[i].h || z->img_comp[i].h > 4) return stbi__err("bad H","Corrupt JPEG"); + z->img_comp[i].v = q & 15; if (!z->img_comp[i].v || z->img_comp[i].v > 4) return stbi__err("bad V","Corrupt JPEG"); + z->img_comp[i].tq = stbi__get8(s); if (z->img_comp[i].tq > 3) return stbi__err("bad TQ","Corrupt JPEG"); + } + + if (scan != STBI__SCAN_load) return 1; + + if (!stbi__mad3sizes_valid(s->img_x, s->img_y, s->img_n, 0)) return stbi__err("too large", "Image too large to decode"); + + for (i=0; i < s->img_n; ++i) { + if (z->img_comp[i].h > h_max) h_max = z->img_comp[i].h; + if (z->img_comp[i].v > v_max) v_max = z->img_comp[i].v; + } + + // compute interleaved mcu info + z->img_h_max = h_max; + z->img_v_max = v_max; + z->img_mcu_w = h_max * 8; + z->img_mcu_h = v_max * 8; + // these sizes can't be more than 17 bits + z->img_mcu_x = (s->img_x + z->img_mcu_w-1) / z->img_mcu_w; + z->img_mcu_y = (s->img_y + z->img_mcu_h-1) / z->img_mcu_h; + + for (i=0; i < s->img_n; ++i) { + // number of effective pixels (e.g. for non-interleaved MCU) + z->img_comp[i].x = (s->img_x * z->img_comp[i].h + h_max-1) / h_max; + z->img_comp[i].y = (s->img_y * z->img_comp[i].v + v_max-1) / v_max; + // to simplify generation, we'll allocate enough memory to decode + // the bogus oversized data from using interleaved MCUs and their + // big blocks (e.g. a 16x16 iMCU on an image of width 33); we won't + // discard the extra data until colorspace conversion + // + // img_mcu_x, img_mcu_y: <=17 bits; comp[i].h and .v are <=4 (checked earlier) + // so these muls can't overflow with 32-bit ints (which we require) + z->img_comp[i].w2 = z->img_mcu_x * z->img_comp[i].h * 8; + z->img_comp[i].h2 = z->img_mcu_y * z->img_comp[i].v * 8; + z->img_comp[i].coeff = 0; + z->img_comp[i].raw_coeff = 0; + z->img_comp[i].linebuf = NULL; + z->img_comp[i].raw_data = stbi__malloc_mad2(z->img_comp[i].w2, z->img_comp[i].h2, 15); + if (z->img_comp[i].raw_data == NULL) + return stbi__free_jpeg_components(z, i+1, stbi__err("outofmem", "Out of memory")); + // align blocks for idct using mmx/sse + z->img_comp[i].data = (stbi_uc*) (((size_t) z->img_comp[i].raw_data + 15) & ~15); + if (z->progressive) { + // w2, h2 are multiples of 8 (see above) + z->img_comp[i].coeff_w = z->img_comp[i].w2 / 8; + z->img_comp[i].coeff_h = z->img_comp[i].h2 / 8; + z->img_comp[i].raw_coeff = stbi__malloc_mad3(z->img_comp[i].w2, z->img_comp[i].h2, sizeof(short), 15); + if (z->img_comp[i].raw_coeff == NULL) + return stbi__free_jpeg_components(z, i+1, stbi__err("outofmem", "Out of memory")); + z->img_comp[i].coeff = (short*) (((size_t) z->img_comp[i].raw_coeff + 15) & ~15); + } + } + + return 1; +} + +// use comparisons since in some cases we handle more than one case (e.g. SOF) +#define stbi__DNL(x) ((x) == 0xdc) +#define stbi__SOI(x) ((x) == 0xd8) +#define stbi__EOI(x) ((x) == 0xd9) +#define stbi__SOF(x) ((x) == 0xc0 || (x) == 0xc1 || (x) == 0xc2) +#define stbi__SOS(x) ((x) == 0xda) + +#define stbi__SOF_progressive(x) ((x) == 0xc2) + +static int stbi__decode_jpeg_header(stbi__jpeg *z, int scan) +{ + int m; + z->jfif = 0; + z->app14_color_transform = -1; // valid values are 0,1,2 + z->marker = STBI__MARKER_none; // initialize cached marker to empty + m = stbi__get_marker(z); + if (!stbi__SOI(m)) return stbi__err("no SOI","Corrupt JPEG"); + if (scan == STBI__SCAN_type) return 1; + m = stbi__get_marker(z); + while (!stbi__SOF(m)) { + if (!stbi__process_marker(z,m)) return 0; + m = stbi__get_marker(z); + while (m == STBI__MARKER_none) { + // some files have extra padding after their blocks, so ok, we'll scan + if (stbi__at_eof(z->s)) return stbi__err("no SOF", "Corrupt JPEG"); + m = stbi__get_marker(z); + } + } + z->progressive = stbi__SOF_progressive(m); + if (!stbi__process_frame_header(z, scan)) return 0; + return 1; +} + +// decode image to YCbCr format +static int stbi__decode_jpeg_image(stbi__jpeg *j) +{ + int m; + for (m = 0; m < 4; m++) { + j->img_comp[m].raw_data = NULL; + j->img_comp[m].raw_coeff = NULL; + } + j->restart_interval = 0; + if (!stbi__decode_jpeg_header(j, STBI__SCAN_load)) return 0; + m = stbi__get_marker(j); + while (!stbi__EOI(m)) { + if (stbi__SOS(m)) { + if (!stbi__process_scan_header(j)) return 0; + if (!stbi__parse_entropy_coded_data(j)) return 0; + if (j->marker == STBI__MARKER_none ) { + // handle 0s at the end of image data from IP Kamera 9060 + while (!stbi__at_eof(j->s)) { + int x = stbi__get8(j->s); + if (x == 255) { + j->marker = stbi__get8(j->s); + break; + } + } + // if we reach eof without hitting a marker, stbi__get_marker() below will fail and we'll eventually return 0 + } + } else if (stbi__DNL(m)) { + int Ld = stbi__get16be(j->s); + stbi__uint32 NL = stbi__get16be(j->s); + if (Ld != 4) return stbi__err("bad DNL len", "Corrupt JPEG"); + if (NL != j->s->img_y) return stbi__err("bad DNL height", "Corrupt JPEG"); + } else { + if (!stbi__process_marker(j, m)) return 0; + } + m = stbi__get_marker(j); + } + if (j->progressive) + stbi__jpeg_finish(j); + return 1; +} + +// static jfif-centered resampling (across block boundaries) + +typedef stbi_uc *(*resample_row_func)(stbi_uc *out, stbi_uc *in0, stbi_uc *in1, + int w, int hs); + +#define stbi__div4(x) ((stbi_uc) ((x) >> 2)) + +static stbi_uc *resample_row_1(stbi_uc *out, stbi_uc *in_near, stbi_uc *in_far, int w, int hs) +{ + STBI_NOTUSED(out); + STBI_NOTUSED(in_far); + STBI_NOTUSED(w); + STBI_NOTUSED(hs); + return in_near; +} + +static stbi_uc* stbi__resample_row_v_2(stbi_uc *out, stbi_uc *in_near, stbi_uc *in_far, int w, int hs) +{ + // need to generate two samples vertically for every one in input + int i; + STBI_NOTUSED(hs); + for (i=0; i < w; ++i) + out[i] = stbi__div4(3*in_near[i] + in_far[i] + 2); + return out; +} + +static stbi_uc* stbi__resample_row_h_2(stbi_uc *out, stbi_uc *in_near, stbi_uc *in_far, int w, int hs) +{ + // need to generate two samples horizontally for every one in input + int i; + stbi_uc *input = in_near; + + if (w == 1) { + // if only one sample, can't do any interpolation + out[0] = out[1] = input[0]; + return out; + } + + out[0] = input[0]; + out[1] = stbi__div4(input[0]*3 + input[1] + 2); + for (i=1; i < w-1; ++i) { + int n = 3*input[i]+2; + out[i*2+0] = stbi__div4(n+input[i-1]); + out[i*2+1] = stbi__div4(n+input[i+1]); + } + out[i*2+0] = stbi__div4(input[w-2]*3 + input[w-1] + 2); + out[i*2+1] = input[w-1]; + + STBI_NOTUSED(in_far); + STBI_NOTUSED(hs); + + return out; +} + +#define stbi__div16(x) ((stbi_uc) ((x) >> 4)) + +static stbi_uc *stbi__resample_row_hv_2(stbi_uc *out, stbi_uc *in_near, stbi_uc *in_far, int w, int hs) +{ + // need to generate 2x2 samples for every one in input + int i,t0,t1; + if (w == 1) { + out[0] = out[1] = stbi__div4(3*in_near[0] + in_far[0] + 2); + return out; + } + + t1 = 3*in_near[0] + in_far[0]; + out[0] = stbi__div4(t1+2); + for (i=1; i < w; ++i) { + t0 = t1; + t1 = 3*in_near[i]+in_far[i]; + out[i*2-1] = stbi__div16(3*t0 + t1 + 8); + out[i*2 ] = stbi__div16(3*t1 + t0 + 8); + } + out[w*2-1] = stbi__div4(t1+2); + + STBI_NOTUSED(hs); + + return out; +} + +#if defined(STBI_SSE2) || defined(STBI_NEON) +static stbi_uc *stbi__resample_row_hv_2_simd(stbi_uc *out, stbi_uc *in_near, stbi_uc *in_far, int w, int hs) +{ + // need to generate 2x2 samples for every one in input + int i=0,t0,t1; + + if (w == 1) { + out[0] = out[1] = stbi__div4(3*in_near[0] + in_far[0] + 2); + return out; + } + + t1 = 3*in_near[0] + in_far[0]; + // process groups of 8 pixels for as long as we can. + // note we can't handle the last pixel in a row in this loop + // because we need to handle the filter boundary conditions. + for (; i < ((w-1) & ~7); i += 8) { +#if defined(STBI_SSE2) + // load and perform the vertical filtering pass + // this uses 3*x + y = 4*x + (y - x) + __m128i zero = _mm_setzero_si128(); + __m128i farb = _mm_loadl_epi64((__m128i *) (in_far + i)); + __m128i nearb = _mm_loadl_epi64((__m128i *) (in_near + i)); + __m128i farw = _mm_unpacklo_epi8(farb, zero); + __m128i nearw = _mm_unpacklo_epi8(nearb, zero); + __m128i diff = _mm_sub_epi16(farw, nearw); + __m128i nears = _mm_slli_epi16(nearw, 2); + __m128i curr = _mm_add_epi16(nears, diff); // current row + + // horizontal filter works the same based on shifted vers of current + // row. "prev" is current row shifted right by 1 pixel; we need to + // insert the previous pixel value (from t1). + // "next" is current row shifted left by 1 pixel, with first pixel + // of next block of 8 pixels added in. + __m128i prv0 = _mm_slli_si128(curr, 2); + __m128i nxt0 = _mm_srli_si128(curr, 2); + __m128i prev = _mm_insert_epi16(prv0, t1, 0); + __m128i next = _mm_insert_epi16(nxt0, 3*in_near[i+8] + in_far[i+8], 7); + + // horizontal filter, polyphase implementation since it's convenient: + // even pixels = 3*cur + prev = cur*4 + (prev - cur) + // odd pixels = 3*cur + next = cur*4 + (next - cur) + // note the shared term. + __m128i bias = _mm_set1_epi16(8); + __m128i curs = _mm_slli_epi16(curr, 2); + __m128i prvd = _mm_sub_epi16(prev, curr); + __m128i nxtd = _mm_sub_epi16(next, curr); + __m128i curb = _mm_add_epi16(curs, bias); + __m128i even = _mm_add_epi16(prvd, curb); + __m128i odd = _mm_add_epi16(nxtd, curb); + + // interleave even and odd pixels, then undo scaling. + __m128i int0 = _mm_unpacklo_epi16(even, odd); + __m128i int1 = _mm_unpackhi_epi16(even, odd); + __m128i de0 = _mm_srli_epi16(int0, 4); + __m128i de1 = _mm_srli_epi16(int1, 4); + + // pack and write output + __m128i outv = _mm_packus_epi16(de0, de1); + _mm_storeu_si128((__m128i *) (out + i*2), outv); +#elif defined(STBI_NEON) + // load and perform the vertical filtering pass + // this uses 3*x + y = 4*x + (y - x) + uint8x8_t farb = vld1_u8(in_far + i); + uint8x8_t nearb = vld1_u8(in_near + i); + int16x8_t diff = vreinterpretq_s16_u16(vsubl_u8(farb, nearb)); + int16x8_t nears = vreinterpretq_s16_u16(vshll_n_u8(nearb, 2)); + int16x8_t curr = vaddq_s16(nears, diff); // current row + + // horizontal filter works the same based on shifted vers of current + // row. "prev" is current row shifted right by 1 pixel; we need to + // insert the previous pixel value (from t1). + // "next" is current row shifted left by 1 pixel, with first pixel + // of next block of 8 pixels added in. + int16x8_t prv0 = vextq_s16(curr, curr, 7); + int16x8_t nxt0 = vextq_s16(curr, curr, 1); + int16x8_t prev = vsetq_lane_s16(t1, prv0, 0); + int16x8_t next = vsetq_lane_s16(3*in_near[i+8] + in_far[i+8], nxt0, 7); + + // horizontal filter, polyphase implementation since it's convenient: + // even pixels = 3*cur + prev = cur*4 + (prev - cur) + // odd pixels = 3*cur + next = cur*4 + (next - cur) + // note the shared term. + int16x8_t curs = vshlq_n_s16(curr, 2); + int16x8_t prvd = vsubq_s16(prev, curr); + int16x8_t nxtd = vsubq_s16(next, curr); + int16x8_t even = vaddq_s16(curs, prvd); + int16x8_t odd = vaddq_s16(curs, nxtd); + + // undo scaling and round, then store with even/odd phases interleaved + uint8x8x2_t o; + o.val[0] = vqrshrun_n_s16(even, 4); + o.val[1] = vqrshrun_n_s16(odd, 4); + vst2_u8(out + i*2, o); +#endif + + // "previous" value for next iter + t1 = 3*in_near[i+7] + in_far[i+7]; + } + + t0 = t1; + t1 = 3*in_near[i] + in_far[i]; + out[i*2] = stbi__div16(3*t1 + t0 + 8); + + for (++i; i < w; ++i) { + t0 = t1; + t1 = 3*in_near[i]+in_far[i]; + out[i*2-1] = stbi__div16(3*t0 + t1 + 8); + out[i*2 ] = stbi__div16(3*t1 + t0 + 8); + } + out[w*2-1] = stbi__div4(t1+2); + + STBI_NOTUSED(hs); + + return out; +} +#endif + +static stbi_uc *stbi__resample_row_generic(stbi_uc *out, stbi_uc *in_near, stbi_uc *in_far, int w, int hs) +{ + // resample with nearest-neighbor + int i,j; + STBI_NOTUSED(in_far); + for (i=0; i < w; ++i) + for (j=0; j < hs; ++j) + out[i*hs+j] = in_near[i]; + return out; +} + +// this is a reduced-precision calculation of YCbCr-to-RGB introduced +// to make sure the code produces the same results in both SIMD and scalar +#define stbi__float2fixed(x) (((int) ((x) * 4096.0f + 0.5f)) << 8) +static void stbi__YCbCr_to_RGB_row(stbi_uc *out, const stbi_uc *y, const stbi_uc *pcb, const stbi_uc *pcr, int count, int step) +{ + int i; + for (i=0; i < count; ++i) { + int y_fixed = (y[i] << 20) + (1<<19); // rounding + int r,g,b; + int cr = pcr[i] - 128; + int cb = pcb[i] - 128; + r = y_fixed + cr* stbi__float2fixed(1.40200f); + g = y_fixed + (cr*-stbi__float2fixed(0.71414f)) + ((cb*-stbi__float2fixed(0.34414f)) & 0xffff0000); + b = y_fixed + cb* stbi__float2fixed(1.77200f); + r >>= 20; + g >>= 20; + b >>= 20; + if ((unsigned) r > 255) { if (r < 0) r = 0; else r = 255; } + if ((unsigned) g > 255) { if (g < 0) g = 0; else g = 255; } + if ((unsigned) b > 255) { if (b < 0) b = 0; else b = 255; } + out[0] = (stbi_uc)r; + out[1] = (stbi_uc)g; + out[2] = (stbi_uc)b; + out[3] = 255; + out += step; + } +} + +#if defined(STBI_SSE2) || defined(STBI_NEON) +static void stbi__YCbCr_to_RGB_simd(stbi_uc *out, stbi_uc const *y, stbi_uc const *pcb, stbi_uc const *pcr, int count, int step) +{ + int i = 0; + +#ifdef STBI_SSE2 + // step == 3 is pretty ugly on the final interleave, and i'm not convinced + // it's useful in practice (you wouldn't use it for textures, for example). + // so just accelerate step == 4 case. + if (step == 4) { + // this is a fairly straightforward implementation and not super-optimized. + __m128i signflip = _mm_set1_epi8(-0x80); + __m128i cr_const0 = _mm_set1_epi16( (short) ( 1.40200f*4096.0f+0.5f)); + __m128i cr_const1 = _mm_set1_epi16( - (short) ( 0.71414f*4096.0f+0.5f)); + __m128i cb_const0 = _mm_set1_epi16( - (short) ( 0.34414f*4096.0f+0.5f)); + __m128i cb_const1 = _mm_set1_epi16( (short) ( 1.77200f*4096.0f+0.5f)); + __m128i y_bias = _mm_set1_epi8((char) (unsigned char) 128); + __m128i xw = _mm_set1_epi16(255); // alpha channel + + for (; i+7 < count; i += 8) { + // load + __m128i y_bytes = _mm_loadl_epi64((__m128i *) (y+i)); + __m128i cr_bytes = _mm_loadl_epi64((__m128i *) (pcr+i)); + __m128i cb_bytes = _mm_loadl_epi64((__m128i *) (pcb+i)); + __m128i cr_biased = _mm_xor_si128(cr_bytes, signflip); // -128 + __m128i cb_biased = _mm_xor_si128(cb_bytes, signflip); // -128 + + // unpack to short (and left-shift cr, cb by 8) + __m128i yw = _mm_unpacklo_epi8(y_bias, y_bytes); + __m128i crw = _mm_unpacklo_epi8(_mm_setzero_si128(), cr_biased); + __m128i cbw = _mm_unpacklo_epi8(_mm_setzero_si128(), cb_biased); + + // color transform + __m128i yws = _mm_srli_epi16(yw, 4); + __m128i cr0 = _mm_mulhi_epi16(cr_const0, crw); + __m128i cb0 = _mm_mulhi_epi16(cb_const0, cbw); + __m128i cb1 = _mm_mulhi_epi16(cbw, cb_const1); + __m128i cr1 = _mm_mulhi_epi16(crw, cr_const1); + __m128i rws = _mm_add_epi16(cr0, yws); + __m128i gwt = _mm_add_epi16(cb0, yws); + __m128i bws = _mm_add_epi16(yws, cb1); + __m128i gws = _mm_add_epi16(gwt, cr1); + + // descale + __m128i rw = _mm_srai_epi16(rws, 4); + __m128i bw = _mm_srai_epi16(bws, 4); + __m128i gw = _mm_srai_epi16(gws, 4); + + // back to byte, set up for transpose + __m128i brb = _mm_packus_epi16(rw, bw); + __m128i gxb = _mm_packus_epi16(gw, xw); + + // transpose to interleave channels + __m128i t0 = _mm_unpacklo_epi8(brb, gxb); + __m128i t1 = _mm_unpackhi_epi8(brb, gxb); + __m128i o0 = _mm_unpacklo_epi16(t0, t1); + __m128i o1 = _mm_unpackhi_epi16(t0, t1); + + // store + _mm_storeu_si128((__m128i *) (out + 0), o0); + _mm_storeu_si128((__m128i *) (out + 16), o1); + out += 32; + } + } +#endif + +#ifdef STBI_NEON + // in this version, step=3 support would be easy to add. but is there demand? + if (step == 4) { + // this is a fairly straightforward implementation and not super-optimized. + uint8x8_t signflip = vdup_n_u8(0x80); + int16x8_t cr_const0 = vdupq_n_s16( (short) ( 1.40200f*4096.0f+0.5f)); + int16x8_t cr_const1 = vdupq_n_s16( - (short) ( 0.71414f*4096.0f+0.5f)); + int16x8_t cb_const0 = vdupq_n_s16( - (short) ( 0.34414f*4096.0f+0.5f)); + int16x8_t cb_const1 = vdupq_n_s16( (short) ( 1.77200f*4096.0f+0.5f)); + + for (; i+7 < count; i += 8) { + // load + uint8x8_t y_bytes = vld1_u8(y + i); + uint8x8_t cr_bytes = vld1_u8(pcr + i); + uint8x8_t cb_bytes = vld1_u8(pcb + i); + int8x8_t cr_biased = vreinterpret_s8_u8(vsub_u8(cr_bytes, signflip)); + int8x8_t cb_biased = vreinterpret_s8_u8(vsub_u8(cb_bytes, signflip)); + + // expand to s16 + int16x8_t yws = vreinterpretq_s16_u16(vshll_n_u8(y_bytes, 4)); + int16x8_t crw = vshll_n_s8(cr_biased, 7); + int16x8_t cbw = vshll_n_s8(cb_biased, 7); + + // color transform + int16x8_t cr0 = vqdmulhq_s16(crw, cr_const0); + int16x8_t cb0 = vqdmulhq_s16(cbw, cb_const0); + int16x8_t cr1 = vqdmulhq_s16(crw, cr_const1); + int16x8_t cb1 = vqdmulhq_s16(cbw, cb_const1); + int16x8_t rws = vaddq_s16(yws, cr0); + int16x8_t gws = vaddq_s16(vaddq_s16(yws, cb0), cr1); + int16x8_t bws = vaddq_s16(yws, cb1); + + // undo scaling, round, convert to byte + uint8x8x4_t o; + o.val[0] = vqrshrun_n_s16(rws, 4); + o.val[1] = vqrshrun_n_s16(gws, 4); + o.val[2] = vqrshrun_n_s16(bws, 4); + o.val[3] = vdup_n_u8(255); + + // store, interleaving r/g/b/a + vst4_u8(out, o); + out += 8*4; + } + } +#endif + + for (; i < count; ++i) { + int y_fixed = (y[i] << 20) + (1<<19); // rounding + int r,g,b; + int cr = pcr[i] - 128; + int cb = pcb[i] - 128; + r = y_fixed + cr* stbi__float2fixed(1.40200f); + g = y_fixed + cr*-stbi__float2fixed(0.71414f) + ((cb*-stbi__float2fixed(0.34414f)) & 0xffff0000); + b = y_fixed + cb* stbi__float2fixed(1.77200f); + r >>= 20; + g >>= 20; + b >>= 20; + if ((unsigned) r > 255) { if (r < 0) r = 0; else r = 255; } + if ((unsigned) g > 255) { if (g < 0) g = 0; else g = 255; } + if ((unsigned) b > 255) { if (b < 0) b = 0; else b = 255; } + out[0] = (stbi_uc)r; + out[1] = (stbi_uc)g; + out[2] = (stbi_uc)b; + out[3] = 255; + out += step; + } +} +#endif + +// set up the kernels +static void stbi__setup_jpeg(stbi__jpeg *j) +{ + j->idct_block_kernel = stbi__idct_block; + j->YCbCr_to_RGB_kernel = stbi__YCbCr_to_RGB_row; + j->resample_row_hv_2_kernel = stbi__resample_row_hv_2; + +#ifdef STBI_SSE2 + if (stbi__sse2_available()) { + j->idct_block_kernel = stbi__idct_simd; + j->YCbCr_to_RGB_kernel = stbi__YCbCr_to_RGB_simd; + j->resample_row_hv_2_kernel = stbi__resample_row_hv_2_simd; + } +#endif + +#ifdef STBI_NEON + j->idct_block_kernel = stbi__idct_simd; + j->YCbCr_to_RGB_kernel = stbi__YCbCr_to_RGB_simd; + j->resample_row_hv_2_kernel = stbi__resample_row_hv_2_simd; +#endif +} + +// clean up the temporary component buffers +static void stbi__cleanup_jpeg(stbi__jpeg *j) +{ + stbi__free_jpeg_components(j, j->s->img_n, 0); +} + +typedef struct +{ + resample_row_func resample; + stbi_uc *line0,*line1; + int hs,vs; // expansion factor in each axis + int w_lores; // horizontal pixels pre-expansion + int ystep; // how far through vertical expansion we are + int ypos; // which pre-expansion row we're on +} stbi__resample; + +// fast 0..255 * 0..255 => 0..255 rounded multiplication +static stbi_uc stbi__blinn_8x8(stbi_uc x, stbi_uc y) +{ + unsigned int t = x*y + 128; + return (stbi_uc) ((t + (t >>8)) >> 8); +} + +static stbi_uc *load_jpeg_image(stbi__jpeg *z, int *out_x, int *out_y, int *comp, int req_comp) +{ + int n, decode_n, is_rgb; + z->s->img_n = 0; // make stbi__cleanup_jpeg safe + + // validate req_comp + if (req_comp < 0 || req_comp > 4) return stbi__errpuc("bad req_comp", "Internal error"); + + // load a jpeg image from whichever source, but leave in YCbCr format + if (!stbi__decode_jpeg_image(z)) { stbi__cleanup_jpeg(z); return NULL; } + + // determine actual number of components to generate + n = req_comp ? req_comp : z->s->img_n >= 3 ? 3 : 1; + + is_rgb = z->s->img_n == 3 && (z->rgb == 3 || (z->app14_color_transform == 0 && !z->jfif)); + + if (z->s->img_n == 3 && n < 3 && !is_rgb) + decode_n = 1; + else + decode_n = z->s->img_n; + + // resample and color-convert + { + int k; + unsigned int i,j; + stbi_uc *output; + stbi_uc *coutput[4] = { NULL, NULL, NULL, NULL }; + + stbi__resample res_comp[4]; + + for (k=0; k < decode_n; ++k) { + stbi__resample *r = &res_comp[k]; + + // allocate line buffer big enough for upsampling off the edges + // with upsample factor of 4 + z->img_comp[k].linebuf = (stbi_uc *) stbi__malloc(z->s->img_x + 3); + if (!z->img_comp[k].linebuf) { stbi__cleanup_jpeg(z); return stbi__errpuc("outofmem", "Out of memory"); } + + r->hs = z->img_h_max / z->img_comp[k].h; + r->vs = z->img_v_max / z->img_comp[k].v; + r->ystep = r->vs >> 1; + r->w_lores = (z->s->img_x + r->hs-1) / r->hs; + r->ypos = 0; + r->line0 = r->line1 = z->img_comp[k].data; + + if (r->hs == 1 && r->vs == 1) r->resample = resample_row_1; + else if (r->hs == 1 && r->vs == 2) r->resample = stbi__resample_row_v_2; + else if (r->hs == 2 && r->vs == 1) r->resample = stbi__resample_row_h_2; + else if (r->hs == 2 && r->vs == 2) r->resample = z->resample_row_hv_2_kernel; + else r->resample = stbi__resample_row_generic; + } + + // can't error after this so, this is safe + output = (stbi_uc *) stbi__malloc_mad3(n, z->s->img_x, z->s->img_y, 1); + if (!output) { stbi__cleanup_jpeg(z); return stbi__errpuc("outofmem", "Out of memory"); } + + // now go ahead and resample + for (j=0; j < z->s->img_y; ++j) { + stbi_uc *out = output + n * z->s->img_x * j; + for (k=0; k < decode_n; ++k) { + stbi__resample *r = &res_comp[k]; + int y_bot = r->ystep >= (r->vs >> 1); + coutput[k] = r->resample(z->img_comp[k].linebuf, + y_bot ? r->line1 : r->line0, + y_bot ? r->line0 : r->line1, + r->w_lores, r->hs); + if (++r->ystep >= r->vs) { + r->ystep = 0; + r->line0 = r->line1; + if (++r->ypos < z->img_comp[k].y) + r->line1 += z->img_comp[k].w2; + } + } + if (n >= 3) { + stbi_uc *y = coutput[0]; + if (z->s->img_n == 3) { + if (is_rgb) { + for (i=0; i < z->s->img_x; ++i) { + out[0] = y[i]; + out[1] = coutput[1][i]; + out[2] = coutput[2][i]; + out[3] = 255; + out += n; + } + } else { + z->YCbCr_to_RGB_kernel(out, y, coutput[1], coutput[2], z->s->img_x, n); + } + } else if (z->s->img_n == 4) { + if (z->app14_color_transform == 0) { // CMYK + for (i=0; i < z->s->img_x; ++i) { + stbi_uc m = coutput[3][i]; + out[0] = stbi__blinn_8x8(coutput[0][i], m); + out[1] = stbi__blinn_8x8(coutput[1][i], m); + out[2] = stbi__blinn_8x8(coutput[2][i], m); + out[3] = 255; + out += n; + } + } else if (z->app14_color_transform == 2) { // YCCK + z->YCbCr_to_RGB_kernel(out, y, coutput[1], coutput[2], z->s->img_x, n); + for (i=0; i < z->s->img_x; ++i) { + stbi_uc m = coutput[3][i]; + out[0] = stbi__blinn_8x8(255 - out[0], m); + out[1] = stbi__blinn_8x8(255 - out[1], m); + out[2] = stbi__blinn_8x8(255 - out[2], m); + out += n; + } + } else { // YCbCr + alpha? Ignore the fourth channel for now + z->YCbCr_to_RGB_kernel(out, y, coutput[1], coutput[2], z->s->img_x, n); + } + } else + for (i=0; i < z->s->img_x; ++i) { + out[0] = out[1] = out[2] = y[i]; + out[3] = 255; // not used if n==3 + out += n; + } + } else { + if (is_rgb) { + if (n == 1) + for (i=0; i < z->s->img_x; ++i) + *out++ = stbi__compute_y(coutput[0][i], coutput[1][i], coutput[2][i]); + else { + for (i=0; i < z->s->img_x; ++i, out += 2) { + out[0] = stbi__compute_y(coutput[0][i], coutput[1][i], coutput[2][i]); + out[1] = 255; + } + } + } else if (z->s->img_n == 4 && z->app14_color_transform == 0) { + for (i=0; i < z->s->img_x; ++i) { + stbi_uc m = coutput[3][i]; + stbi_uc r = stbi__blinn_8x8(coutput[0][i], m); + stbi_uc g = stbi__blinn_8x8(coutput[1][i], m); + stbi_uc b = stbi__blinn_8x8(coutput[2][i], m); + out[0] = stbi__compute_y(r, g, b); + out[1] = 255; + out += n; + } + } else if (z->s->img_n == 4 && z->app14_color_transform == 2) { + for (i=0; i < z->s->img_x; ++i) { + out[0] = stbi__blinn_8x8(255 - coutput[0][i], coutput[3][i]); + out[1] = 255; + out += n; + } + } else { + stbi_uc *y = coutput[0]; + if (n == 1) + for (i=0; i < z->s->img_x; ++i) out[i] = y[i]; + else + for (i=0; i < z->s->img_x; ++i) { *out++ = y[i]; *out++ = 255; } + } + } + } + stbi__cleanup_jpeg(z); + *out_x = z->s->img_x; + *out_y = z->s->img_y; + if (comp) *comp = z->s->img_n >= 3 ? 3 : 1; // report original components, not output + return output; + } +} + +static void *stbi__jpeg_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +{ + unsigned char* result; + stbi__jpeg* j = (stbi__jpeg*) stbi__malloc(sizeof(stbi__jpeg)); + STBI_NOTUSED(ri); + j->s = s; + stbi__setup_jpeg(j); + result = load_jpeg_image(j, x,y,comp,req_comp); + STBI_FREE(j); + return result; +} + +static int stbi__jpeg_test(stbi__context *s) +{ + int r; + stbi__jpeg* j = (stbi__jpeg*)stbi__malloc(sizeof(stbi__jpeg)); + j->s = s; + stbi__setup_jpeg(j); + r = stbi__decode_jpeg_header(j, STBI__SCAN_type); + stbi__rewind(s); + STBI_FREE(j); + return r; +} + +static int stbi__jpeg_info_raw(stbi__jpeg *j, int *x, int *y, int *comp) +{ + if (!stbi__decode_jpeg_header(j, STBI__SCAN_header)) { + stbi__rewind( j->s ); + return 0; + } + if (x) *x = j->s->img_x; + if (y) *y = j->s->img_y; + if (comp) *comp = j->s->img_n >= 3 ? 3 : 1; + return 1; +} + +static int stbi__jpeg_info(stbi__context *s, int *x, int *y, int *comp) +{ + int result; + stbi__jpeg* j = (stbi__jpeg*) (stbi__malloc(sizeof(stbi__jpeg))); + j->s = s; + result = stbi__jpeg_info_raw(j, x, y, comp); + STBI_FREE(j); + return result; +} +#endif + +// public domain zlib decode v0.2 Sean Barrett 2006-11-18 +// simple implementation +// - all input must be provided in an upfront buffer +// - all output is written to a single output buffer (can malloc/realloc) +// performance +// - fast huffman + +#ifndef STBI_NO_ZLIB + +// fast-way is faster to check than jpeg huffman, but slow way is slower +#define STBI__ZFAST_BITS 9 // accelerate all cases in default tables +#define STBI__ZFAST_MASK ((1 << STBI__ZFAST_BITS) - 1) + +// zlib-style huffman encoding +// (jpegs packs from left, zlib from right, so can't share code) +typedef struct +{ + stbi__uint16 fast[1 << STBI__ZFAST_BITS]; + stbi__uint16 firstcode[16]; + int maxcode[17]; + stbi__uint16 firstsymbol[16]; + stbi_uc size[288]; + stbi__uint16 value[288]; +} stbi__zhuffman; + +stbi_inline static int stbi__bitreverse16(int n) +{ + n = ((n & 0xAAAA) >> 1) | ((n & 0x5555) << 1); + n = ((n & 0xCCCC) >> 2) | ((n & 0x3333) << 2); + n = ((n & 0xF0F0) >> 4) | ((n & 0x0F0F) << 4); + n = ((n & 0xFF00) >> 8) | ((n & 0x00FF) << 8); + return n; +} + +stbi_inline static int stbi__bit_reverse(int v, int bits) +{ + STBI_ASSERT(bits <= 16); + // to bit reverse n bits, reverse 16 and shift + // e.g. 11 bits, bit reverse and shift away 5 + return stbi__bitreverse16(v) >> (16-bits); +} + +static int stbi__zbuild_huffman(stbi__zhuffman *z, const stbi_uc *sizelist, int num) +{ + int i,k=0; + int code, next_code[16], sizes[17]; + + // DEFLATE spec for generating codes + memset(sizes, 0, sizeof(sizes)); + memset(z->fast, 0, sizeof(z->fast)); + for (i=0; i < num; ++i) + ++sizes[sizelist[i]]; + sizes[0] = 0; + for (i=1; i < 16; ++i) + if (sizes[i] > (1 << i)) + return stbi__err("bad sizes", "Corrupt PNG"); + code = 0; + for (i=1; i < 16; ++i) { + next_code[i] = code; + z->firstcode[i] = (stbi__uint16) code; + z->firstsymbol[i] = (stbi__uint16) k; + code = (code + sizes[i]); + if (sizes[i]) + if (code-1 >= (1 << i)) return stbi__err("bad codelengths","Corrupt PNG"); + z->maxcode[i] = code << (16-i); // preshift for inner loop + code <<= 1; + k += sizes[i]; + } + z->maxcode[16] = 0x10000; // sentinel + for (i=0; i < num; ++i) { + int s = sizelist[i]; + if (s) { + int c = next_code[s] - z->firstcode[s] + z->firstsymbol[s]; + stbi__uint16 fastv = (stbi__uint16) ((s << 9) | i); + z->size [c] = (stbi_uc ) s; + z->value[c] = (stbi__uint16) i; + if (s <= STBI__ZFAST_BITS) { + int j = stbi__bit_reverse(next_code[s],s); + while (j < (1 << STBI__ZFAST_BITS)) { + z->fast[j] = fastv; + j += (1 << s); + } + } + ++next_code[s]; + } + } + return 1; +} + +// zlib-from-memory implementation for PNG reading +// because PNG allows splitting the zlib stream arbitrarily, +// and it's annoying structurally to have PNG call ZLIB call PNG, +// we require PNG read all the IDATs and combine them into a single +// memory buffer + +typedef struct +{ + stbi_uc *zbuffer, *zbuffer_end; + int num_bits; + stbi__uint32 code_buffer; + + char *zout; + char *zout_start; + char *zout_end; + int z_expandable; + + stbi__zhuffman z_length, z_distance; +} stbi__zbuf; + +stbi_inline static int stbi__zeof(stbi__zbuf *z) +{ + return (z->zbuffer >= z->zbuffer_end); +} + +stbi_inline static stbi_uc stbi__zget8(stbi__zbuf *z) +{ + return stbi__zeof(z) ? 0 : *z->zbuffer++; +} + +static void stbi__fill_bits(stbi__zbuf *z) +{ + do { + if (z->code_buffer >= (1U << z->num_bits)) { + z->zbuffer = z->zbuffer_end; /* treat this as EOF so we fail. */ + return; + } + z->code_buffer |= (unsigned int) stbi__zget8(z) << z->num_bits; + z->num_bits += 8; + } while (z->num_bits <= 24); +} + +stbi_inline static unsigned int stbi__zreceive(stbi__zbuf *z, int n) +{ + unsigned int k; + if (z->num_bits < n) stbi__fill_bits(z); + k = z->code_buffer & ((1 << n) - 1); + z->code_buffer >>= n; + z->num_bits -= n; + return k; +} + +static int stbi__zhuffman_decode_slowpath(stbi__zbuf *a, stbi__zhuffman *z) +{ + int b,s,k; + // not resolved by fast table, so compute it the slow way + // use jpeg approach, which requires MSbits at top + k = stbi__bit_reverse(a->code_buffer, 16); + for (s=STBI__ZFAST_BITS+1; ; ++s) + if (k < z->maxcode[s]) + break; + if (s >= 16) return -1; // invalid code! + // code size is s, so: + b = (k >> (16-s)) - z->firstcode[s] + z->firstsymbol[s]; + if (b >= sizeof (z->size)) return -1; // some data was corrupt somewhere! + if (z->size[b] != s) return -1; // was originally an assert, but report failure instead. + a->code_buffer >>= s; + a->num_bits -= s; + return z->value[b]; +} + +stbi_inline static int stbi__zhuffman_decode(stbi__zbuf *a, stbi__zhuffman *z) +{ + int b,s; + if (a->num_bits < 16) { + if (stbi__zeof(a)) { + return -1; /* report error for unexpected end of data. */ + } + stbi__fill_bits(a); + } + b = z->fast[a->code_buffer & STBI__ZFAST_MASK]; + if (b) { + s = b >> 9; + a->code_buffer >>= s; + a->num_bits -= s; + return b & 511; + } + return stbi__zhuffman_decode_slowpath(a, z); +} + +static int stbi__zexpand(stbi__zbuf *z, char *zout, int n) // need to make room for n bytes +{ + char *q; + unsigned int cur, limit, old_limit; + z->zout = zout; + if (!z->z_expandable) return stbi__err("output buffer limit","Corrupt PNG"); + cur = (unsigned int) (z->zout - z->zout_start); + limit = old_limit = (unsigned) (z->zout_end - z->zout_start); + if (UINT_MAX - cur < (unsigned) n) return stbi__err("outofmem", "Out of memory"); + while (cur + n > limit) { + if(limit > UINT_MAX / 2) return stbi__err("outofmem", "Out of memory"); + limit *= 2; + } + q = (char *) STBI_REALLOC_SIZED(z->zout_start, old_limit, limit); + STBI_NOTUSED(old_limit); + if (q == NULL) return stbi__err("outofmem", "Out of memory"); + z->zout_start = q; + z->zout = q + cur; + z->zout_end = q + limit; + return 1; +} + +static const int stbi__zlength_base[31] = { + 3,4,5,6,7,8,9,10,11,13, + 15,17,19,23,27,31,35,43,51,59, + 67,83,99,115,131,163,195,227,258,0,0 }; + +static const int stbi__zlength_extra[31]= +{ 0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0 }; + +static const int stbi__zdist_base[32] = { 1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193, +257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577,0,0}; + +static const int stbi__zdist_extra[32] = +{ 0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13}; + +static int stbi__parse_huffman_block(stbi__zbuf *a) +{ + char *zout = a->zout; + for(;;) { + int z = stbi__zhuffman_decode(a, &a->z_length); + if (z < 256) { + if (z < 0) return stbi__err("bad huffman code","Corrupt PNG"); // error in huffman codes + if (zout >= a->zout_end) { + if (!stbi__zexpand(a, zout, 1)) return 0; + zout = a->zout; + } + *zout++ = (char) z; + } else { + stbi_uc *p; + int len,dist; + if (z == 256) { + a->zout = zout; + return 1; + } + z -= 257; + len = stbi__zlength_base[z]; + if (stbi__zlength_extra[z]) len += stbi__zreceive(a, stbi__zlength_extra[z]); + z = stbi__zhuffman_decode(a, &a->z_distance); + if (z < 0) return stbi__err("bad huffman code","Corrupt PNG"); + dist = stbi__zdist_base[z]; + if (stbi__zdist_extra[z]) dist += stbi__zreceive(a, stbi__zdist_extra[z]); + if (zout - a->zout_start < dist) return stbi__err("bad dist","Corrupt PNG"); + if (zout + len > a->zout_end) { + if (!stbi__zexpand(a, zout, len)) return 0; + zout = a->zout; + } + p = (stbi_uc *) (zout - dist); + if (dist == 1) { // run of one byte; common in images. + stbi_uc v = *p; + if (len) { do *zout++ = v; while (--len); } + } else { + if (len) { do *zout++ = *p++; while (--len); } + } + } + } +} + +static int stbi__compute_huffman_codes(stbi__zbuf *a) +{ + static const stbi_uc length_dezigzag[19] = { 16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15 }; + stbi__zhuffman z_codelength; + stbi_uc lencodes[286+32+137];//padding for maximum single op + stbi_uc codelength_sizes[19]; + int i,n; + + int hlit = stbi__zreceive(a,5) + 257; + int hdist = stbi__zreceive(a,5) + 1; + int hclen = stbi__zreceive(a,4) + 4; + int ntot = hlit + hdist; + + memset(codelength_sizes, 0, sizeof(codelength_sizes)); + for (i=0; i < hclen; ++i) { + int s = stbi__zreceive(a,3); + codelength_sizes[length_dezigzag[i]] = (stbi_uc) s; + } + if (!stbi__zbuild_huffman(&z_codelength, codelength_sizes, 19)) return 0; + + n = 0; + while (n < ntot) { + int c = stbi__zhuffman_decode(a, &z_codelength); + if (c < 0 || c >= 19) return stbi__err("bad codelengths", "Corrupt PNG"); + if (c < 16) + lencodes[n++] = (stbi_uc) c; + else { + stbi_uc fill = 0; + if (c == 16) { + c = stbi__zreceive(a,2)+3; + if (n == 0) return stbi__err("bad codelengths", "Corrupt PNG"); + fill = lencodes[n-1]; + } else if (c == 17) { + c = stbi__zreceive(a,3)+3; + } else if (c == 18) { + c = stbi__zreceive(a,7)+11; + } else { + return stbi__err("bad codelengths", "Corrupt PNG"); + } + if (ntot - n < c) return stbi__err("bad codelengths", "Corrupt PNG"); + memset(lencodes+n, fill, c); + n += c; + } + } + if (n != ntot) return stbi__err("bad codelengths","Corrupt PNG"); + if (!stbi__zbuild_huffman(&a->z_length, lencodes, hlit)) return 0; + if (!stbi__zbuild_huffman(&a->z_distance, lencodes+hlit, hdist)) return 0; + return 1; +} + +static int stbi__parse_uncompressed_block(stbi__zbuf *a) +{ + stbi_uc header[4]; + int len,nlen,k; + if (a->num_bits & 7) + stbi__zreceive(a, a->num_bits & 7); // discard + // drain the bit-packed data into header + k = 0; + while (a->num_bits > 0) { + header[k++] = (stbi_uc) (a->code_buffer & 255); // suppress MSVC run-time check + a->code_buffer >>= 8; + a->num_bits -= 8; + } + if (a->num_bits < 0) return stbi__err("zlib corrupt","Corrupt PNG"); + // now fill header the normal way + while (k < 4) + header[k++] = stbi__zget8(a); + len = header[1] * 256 + header[0]; + nlen = header[3] * 256 + header[2]; + if (nlen != (len ^ 0xffff)) return stbi__err("zlib corrupt","Corrupt PNG"); + if (a->zbuffer + len > a->zbuffer_end) return stbi__err("read past buffer","Corrupt PNG"); + if (a->zout + len > a->zout_end) + if (!stbi__zexpand(a, a->zout, len)) return 0; + memcpy(a->zout, a->zbuffer, len); + a->zbuffer += len; + a->zout += len; + return 1; +} + +static int stbi__parse_zlib_header(stbi__zbuf *a) +{ + int cmf = stbi__zget8(a); + int cm = cmf & 15; + /* int cinfo = cmf >> 4; */ + int flg = stbi__zget8(a); + if (stbi__zeof(a)) return stbi__err("bad zlib header","Corrupt PNG"); // zlib spec + if ((cmf*256+flg) % 31 != 0) return stbi__err("bad zlib header","Corrupt PNG"); // zlib spec + if (flg & 32) return stbi__err("no preset dict","Corrupt PNG"); // preset dictionary not allowed in png + if (cm != 8) return stbi__err("bad compression","Corrupt PNG"); // DEFLATE required for png + // window = 1 << (8 + cinfo)... but who cares, we fully buffer output + return 1; +} + +static const stbi_uc stbi__zdefault_length[288] = +{ + 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, + 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, + 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, + 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, + 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, + 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, + 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, + 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, + 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,8,8,8,8,8,8,8,8 +}; +static const stbi_uc stbi__zdefault_distance[32] = +{ + 5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5 +}; +/* +Init algorithm: +{ + int i; // use <= to match clearly with spec + for (i=0; i <= 143; ++i) stbi__zdefault_length[i] = 8; + for ( ; i <= 255; ++i) stbi__zdefault_length[i] = 9; + for ( ; i <= 279; ++i) stbi__zdefault_length[i] = 7; + for ( ; i <= 287; ++i) stbi__zdefault_length[i] = 8; + + for (i=0; i <= 31; ++i) stbi__zdefault_distance[i] = 5; +} +*/ + +static int stbi__parse_zlib(stbi__zbuf *a, int parse_header) +{ + int final, type; + if (parse_header) + if (!stbi__parse_zlib_header(a)) return 0; + a->num_bits = 0; + a->code_buffer = 0; + do { + final = stbi__zreceive(a,1); + type = stbi__zreceive(a,2); + if (type == 0) { + if (!stbi__parse_uncompressed_block(a)) return 0; + } else if (type == 3) { + return 0; + } else { + if (type == 1) { + // use fixed code lengths + if (!stbi__zbuild_huffman(&a->z_length , stbi__zdefault_length , 288)) return 0; + if (!stbi__zbuild_huffman(&a->z_distance, stbi__zdefault_distance, 32)) return 0; + } else { + if (!stbi__compute_huffman_codes(a)) return 0; + } + if (!stbi__parse_huffman_block(a)) return 0; + } + } while (!final); + return 1; +} + +static int stbi__do_zlib(stbi__zbuf *a, char *obuf, int olen, int exp, int parse_header) +{ + a->zout_start = obuf; + a->zout = obuf; + a->zout_end = obuf + olen; + a->z_expandable = exp; + + return stbi__parse_zlib(a, parse_header); +} + +STBIDEF char *stbi_zlib_decode_malloc_guesssize(const char *buffer, int len, int initial_size, int *outlen) +{ + stbi__zbuf a; + char *p = (char *) stbi__malloc(initial_size); + if (p == NULL) return NULL; + a.zbuffer = (stbi_uc *) buffer; + a.zbuffer_end = (stbi_uc *) buffer + len; + if (stbi__do_zlib(&a, p, initial_size, 1, 1)) { + if (outlen) *outlen = (int) (a.zout - a.zout_start); + return a.zout_start; + } else { + STBI_FREE(a.zout_start); + return NULL; + } +} + +STBIDEF char *stbi_zlib_decode_malloc(char const *buffer, int len, int *outlen) +{ + return stbi_zlib_decode_malloc_guesssize(buffer, len, 16384, outlen); +} + +STBIDEF char *stbi_zlib_decode_malloc_guesssize_headerflag(const char *buffer, int len, int initial_size, int *outlen, int parse_header) +{ + stbi__zbuf a; + char *p = (char *) stbi__malloc(initial_size); + if (p == NULL) return NULL; + a.zbuffer = (stbi_uc *) buffer; + a.zbuffer_end = (stbi_uc *) buffer + len; + if (stbi__do_zlib(&a, p, initial_size, 1, parse_header)) { + if (outlen) *outlen = (int) (a.zout - a.zout_start); + return a.zout_start; + } else { + STBI_FREE(a.zout_start); + return NULL; + } +} + +STBIDEF int stbi_zlib_decode_buffer(char *obuffer, int olen, char const *ibuffer, int ilen) +{ + stbi__zbuf a; + a.zbuffer = (stbi_uc *) ibuffer; + a.zbuffer_end = (stbi_uc *) ibuffer + ilen; + if (stbi__do_zlib(&a, obuffer, olen, 0, 1)) + return (int) (a.zout - a.zout_start); + else + return -1; +} + +STBIDEF char *stbi_zlib_decode_noheader_malloc(char const *buffer, int len, int *outlen) +{ + stbi__zbuf a; + char *p = (char *) stbi__malloc(16384); + if (p == NULL) return NULL; + a.zbuffer = (stbi_uc *) buffer; + a.zbuffer_end = (stbi_uc *) buffer+len; + if (stbi__do_zlib(&a, p, 16384, 1, 0)) { + if (outlen) *outlen = (int) (a.zout - a.zout_start); + return a.zout_start; + } else { + STBI_FREE(a.zout_start); + return NULL; + } +} + +STBIDEF int stbi_zlib_decode_noheader_buffer(char *obuffer, int olen, const char *ibuffer, int ilen) +{ + stbi__zbuf a; + a.zbuffer = (stbi_uc *) ibuffer; + a.zbuffer_end = (stbi_uc *) ibuffer + ilen; + if (stbi__do_zlib(&a, obuffer, olen, 0, 0)) + return (int) (a.zout - a.zout_start); + else + return -1; +} +#endif + +// public domain "baseline" PNG decoder v0.10 Sean Barrett 2006-11-18 +// simple implementation +// - only 8-bit samples +// - no CRC checking +// - allocates lots of intermediate memory +// - avoids problem of streaming data between subsystems +// - avoids explicit window management +// performance +// - uses stb_zlib, a PD zlib implementation with fast huffman decoding + +#ifndef STBI_NO_PNG +typedef struct +{ + stbi__uint32 length; + stbi__uint32 type; +} stbi__pngchunk; + +static stbi__pngchunk stbi__get_chunk_header(stbi__context *s) +{ + stbi__pngchunk c; + c.length = stbi__get32be(s); + c.type = stbi__get32be(s); + return c; +} + +static int stbi__check_png_header(stbi__context *s) +{ + static const stbi_uc png_sig[8] = { 137,80,78,71,13,10,26,10 }; + int i; + for (i=0; i < 8; ++i) + if (stbi__get8(s) != png_sig[i]) return stbi__err("bad png sig","Not a PNG"); + return 1; +} + +typedef struct +{ + stbi__context *s; + stbi_uc *idata, *expanded, *out; + int depth; +} stbi__png; + + +enum { + STBI__F_none=0, + STBI__F_sub=1, + STBI__F_up=2, + STBI__F_avg=3, + STBI__F_paeth=4, + // synthetic filters used for first scanline to avoid needing a dummy row of 0s + STBI__F_avg_first, + STBI__F_paeth_first +}; + +static stbi_uc first_row_filter[5] = +{ + STBI__F_none, + STBI__F_sub, + STBI__F_none, + STBI__F_avg_first, + STBI__F_paeth_first +}; + +static int stbi__paeth(int a, int b, int c) +{ + int p = a + b - c; + int pa = abs(p-a); + int pb = abs(p-b); + int pc = abs(p-c); + if (pa <= pb && pa <= pc) return a; + if (pb <= pc) return b; + return c; +} + +static const stbi_uc stbi__depth_scale_table[9] = { 0, 0xff, 0x55, 0, 0x11, 0,0,0, 0x01 }; + +// create the png data from post-deflated data +static int stbi__create_png_image_raw(stbi__png *a, stbi_uc *raw, stbi__uint32 raw_len, int out_n, stbi__uint32 x, stbi__uint32 y, int depth, int color) +{ + int bytes = (depth == 16? 2 : 1); + stbi__context *s = a->s; + stbi__uint32 i,j,stride = x*out_n*bytes; + stbi__uint32 img_len, img_width_bytes; + int k; + int img_n = s->img_n; // copy it into a local for later + + int output_bytes = out_n*bytes; + int filter_bytes = img_n*bytes; + int width = x; + + STBI_ASSERT(out_n == s->img_n || out_n == s->img_n+1); + a->out = (stbi_uc *) stbi__malloc_mad3(x, y, output_bytes, 0); // extra bytes to write off the end into + if (!a->out) return stbi__err("outofmem", "Out of memory"); + + if (!stbi__mad3sizes_valid(img_n, x, depth, 7)) return stbi__err("too large", "Corrupt PNG"); + img_width_bytes = (((img_n * x * depth) + 7) >> 3); + img_len = (img_width_bytes + 1) * y; + + // we used to check for exact match between raw_len and img_len on non-interlaced PNGs, + // but issue #276 reported a PNG in the wild that had extra data at the end (all zeros), + // so just check for raw_len < img_len always. + if (raw_len < img_len) return stbi__err("not enough pixels","Corrupt PNG"); + + for (j=0; j < y; ++j) { + stbi_uc *cur = a->out + stride*j; + stbi_uc *prior; + int filter = *raw++; + + if (filter > 4) + return stbi__err("invalid filter","Corrupt PNG"); + + if (depth < 8) { + if (img_width_bytes > x) return stbi__err("invalid width","Corrupt PNG"); + cur += x*out_n - img_width_bytes; // store output to the rightmost img_len bytes, so we can decode in place + filter_bytes = 1; + width = img_width_bytes; + } + prior = cur - stride; // bugfix: need to compute this after 'cur +=' computation above + + // if first row, use special filter that doesn't sample previous row + if (j == 0) filter = first_row_filter[filter]; + + // handle first byte explicitly + for (k=0; k < filter_bytes; ++k) { + switch (filter) { + case STBI__F_none : cur[k] = raw[k]; break; + case STBI__F_sub : cur[k] = raw[k]; break; + case STBI__F_up : cur[k] = STBI__BYTECAST(raw[k] + prior[k]); break; + case STBI__F_avg : cur[k] = STBI__BYTECAST(raw[k] + (prior[k]>>1)); break; + case STBI__F_paeth : cur[k] = STBI__BYTECAST(raw[k] + stbi__paeth(0,prior[k],0)); break; + case STBI__F_avg_first : cur[k] = raw[k]; break; + case STBI__F_paeth_first: cur[k] = raw[k]; break; + } + } + + if (depth == 8) { + if (img_n != out_n) + cur[img_n] = 255; // first pixel + raw += img_n; + cur += out_n; + prior += out_n; + } else if (depth == 16) { + if (img_n != out_n) { + cur[filter_bytes] = 255; // first pixel top byte + cur[filter_bytes+1] = 255; // first pixel bottom byte + } + raw += filter_bytes; + cur += output_bytes; + prior += output_bytes; + } else { + raw += 1; + cur += 1; + prior += 1; + } + + // this is a little gross, so that we don't switch per-pixel or per-component + if (depth < 8 || img_n == out_n) { + int nk = (width - 1)*filter_bytes; + #define STBI__CASE(f) \ + case f: \ + for (k=0; k < nk; ++k) + switch (filter) { + // "none" filter turns into a memcpy here; make that explicit. + case STBI__F_none: memcpy(cur, raw, nk); break; + STBI__CASE(STBI__F_sub) { cur[k] = STBI__BYTECAST(raw[k] + cur[k-filter_bytes]); } break; + STBI__CASE(STBI__F_up) { cur[k] = STBI__BYTECAST(raw[k] + prior[k]); } break; + STBI__CASE(STBI__F_avg) { cur[k] = STBI__BYTECAST(raw[k] + ((prior[k] + cur[k-filter_bytes])>>1)); } break; + STBI__CASE(STBI__F_paeth) { cur[k] = STBI__BYTECAST(raw[k] + stbi__paeth(cur[k-filter_bytes],prior[k],prior[k-filter_bytes])); } break; + STBI__CASE(STBI__F_avg_first) { cur[k] = STBI__BYTECAST(raw[k] + (cur[k-filter_bytes] >> 1)); } break; + STBI__CASE(STBI__F_paeth_first) { cur[k] = STBI__BYTECAST(raw[k] + stbi__paeth(cur[k-filter_bytes],0,0)); } break; + } + #undef STBI__CASE + raw += nk; + } else { + STBI_ASSERT(img_n+1 == out_n); + #define STBI__CASE(f) \ + case f: \ + for (i=x-1; i >= 1; --i, cur[filter_bytes]=255,raw+=filter_bytes,cur+=output_bytes,prior+=output_bytes) \ + for (k=0; k < filter_bytes; ++k) + switch (filter) { + STBI__CASE(STBI__F_none) { cur[k] = raw[k]; } break; + STBI__CASE(STBI__F_sub) { cur[k] = STBI__BYTECAST(raw[k] + cur[k- output_bytes]); } break; + STBI__CASE(STBI__F_up) { cur[k] = STBI__BYTECAST(raw[k] + prior[k]); } break; + STBI__CASE(STBI__F_avg) { cur[k] = STBI__BYTECAST(raw[k] + ((prior[k] + cur[k- output_bytes])>>1)); } break; + STBI__CASE(STBI__F_paeth) { cur[k] = STBI__BYTECAST(raw[k] + stbi__paeth(cur[k- output_bytes],prior[k],prior[k- output_bytes])); } break; + STBI__CASE(STBI__F_avg_first) { cur[k] = STBI__BYTECAST(raw[k] + (cur[k- output_bytes] >> 1)); } break; + STBI__CASE(STBI__F_paeth_first) { cur[k] = STBI__BYTECAST(raw[k] + stbi__paeth(cur[k- output_bytes],0,0)); } break; + } + #undef STBI__CASE + + // the loop above sets the high byte of the pixels' alpha, but for + // 16 bit png files we also need the low byte set. we'll do that here. + if (depth == 16) { + cur = a->out + stride*j; // start at the beginning of the row again + for (i=0; i < x; ++i,cur+=output_bytes) { + cur[filter_bytes+1] = 255; + } + } + } + } + + // we make a separate pass to expand bits to pixels; for performance, + // this could run two scanlines behind the above code, so it won't + // intefere with filtering but will still be in the cache. + if (depth < 8) { + for (j=0; j < y; ++j) { + stbi_uc *cur = a->out + stride*j; + stbi_uc *in = a->out + stride*j + x*out_n - img_width_bytes; + // unpack 1/2/4-bit into a 8-bit buffer. allows us to keep the common 8-bit path optimal at minimal cost for 1/2/4-bit + // png guarante byte alignment, if width is not multiple of 8/4/2 we'll decode dummy trailing data that will be skipped in the later loop + stbi_uc scale = (color == 0) ? stbi__depth_scale_table[depth] : 1; // scale grayscale values to 0..255 range + + // note that the final byte might overshoot and write more data than desired. + // we can allocate enough data that this never writes out of memory, but it + // could also overwrite the next scanline. can it overwrite non-empty data + // on the next scanline? yes, consider 1-pixel-wide scanlines with 1-bit-per-pixel. + // so we need to explicitly clamp the final ones + + if (depth == 4) { + for (k=x*img_n; k >= 2; k-=2, ++in) { + *cur++ = scale * ((*in >> 4) ); + *cur++ = scale * ((*in ) & 0x0f); + } + if (k > 0) *cur++ = scale * ((*in >> 4) ); + } else if (depth == 2) { + for (k=x*img_n; k >= 4; k-=4, ++in) { + *cur++ = scale * ((*in >> 6) ); + *cur++ = scale * ((*in >> 4) & 0x03); + *cur++ = scale * ((*in >> 2) & 0x03); + *cur++ = scale * ((*in ) & 0x03); + } + if (k > 0) *cur++ = scale * ((*in >> 6) ); + if (k > 1) *cur++ = scale * ((*in >> 4) & 0x03); + if (k > 2) *cur++ = scale * ((*in >> 2) & 0x03); + } else if (depth == 1) { + for (k=x*img_n; k >= 8; k-=8, ++in) { + *cur++ = scale * ((*in >> 7) ); + *cur++ = scale * ((*in >> 6) & 0x01); + *cur++ = scale * ((*in >> 5) & 0x01); + *cur++ = scale * ((*in >> 4) & 0x01); + *cur++ = scale * ((*in >> 3) & 0x01); + *cur++ = scale * ((*in >> 2) & 0x01); + *cur++ = scale * ((*in >> 1) & 0x01); + *cur++ = scale * ((*in ) & 0x01); + } + if (k > 0) *cur++ = scale * ((*in >> 7) ); + if (k > 1) *cur++ = scale * ((*in >> 6) & 0x01); + if (k > 2) *cur++ = scale * ((*in >> 5) & 0x01); + if (k > 3) *cur++ = scale * ((*in >> 4) & 0x01); + if (k > 4) *cur++ = scale * ((*in >> 3) & 0x01); + if (k > 5) *cur++ = scale * ((*in >> 2) & 0x01); + if (k > 6) *cur++ = scale * ((*in >> 1) & 0x01); + } + if (img_n != out_n) { + int q; + // insert alpha = 255 + cur = a->out + stride*j; + if (img_n == 1) { + for (q=x-1; q >= 0; --q) { + cur[q*2+1] = 255; + cur[q*2+0] = cur[q]; + } + } else { + STBI_ASSERT(img_n == 3); + for (q=x-1; q >= 0; --q) { + cur[q*4+3] = 255; + cur[q*4+2] = cur[q*3+2]; + cur[q*4+1] = cur[q*3+1]; + cur[q*4+0] = cur[q*3+0]; + } + } + } + } + } else if (depth == 16) { + // force the image data from big-endian to platform-native. + // this is done in a separate pass due to the decoding relying + // on the data being untouched, but could probably be done + // per-line during decode if care is taken. + stbi_uc *cur = a->out; + stbi__uint16 *cur16 = (stbi__uint16*)cur; + + for(i=0; i < x*y*out_n; ++i,cur16++,cur+=2) { + *cur16 = (cur[0] << 8) | cur[1]; + } + } + + return 1; +} + +static int stbi__create_png_image(stbi__png *a, stbi_uc *image_data, stbi__uint32 image_data_len, int out_n, int depth, int color, int interlaced) +{ + int bytes = (depth == 16 ? 2 : 1); + int out_bytes = out_n * bytes; + stbi_uc *final; + int p; + if (!interlaced) + return stbi__create_png_image_raw(a, image_data, image_data_len, out_n, a->s->img_x, a->s->img_y, depth, color); + + // de-interlacing + final = (stbi_uc *) stbi__malloc_mad3(a->s->img_x, a->s->img_y, out_bytes, 0); + for (p=0; p < 7; ++p) { + int xorig[] = { 0,4,0,2,0,1,0 }; + int yorig[] = { 0,0,4,0,2,0,1 }; + int xspc[] = { 8,8,4,4,2,2,1 }; + int yspc[] = { 8,8,8,4,4,2,2 }; + int i,j,x,y; + // pass1_x[4] = 0, pass1_x[5] = 1, pass1_x[12] = 1 + x = (a->s->img_x - xorig[p] + xspc[p]-1) / xspc[p]; + y = (a->s->img_y - yorig[p] + yspc[p]-1) / yspc[p]; + if (x && y) { + stbi__uint32 img_len = ((((a->s->img_n * x * depth) + 7) >> 3) + 1) * y; + if (!stbi__create_png_image_raw(a, image_data, image_data_len, out_n, x, y, depth, color)) { + STBI_FREE(final); + return 0; + } + for (j=0; j < y; ++j) { + for (i=0; i < x; ++i) { + int out_y = j*yspc[p]+yorig[p]; + int out_x = i*xspc[p]+xorig[p]; + memcpy(final + out_y*a->s->img_x*out_bytes + out_x*out_bytes, + a->out + (j*x+i)*out_bytes, out_bytes); + } + } + STBI_FREE(a->out); + image_data += img_len; + image_data_len -= img_len; + } + } + a->out = final; + + return 1; +} + +static int stbi__compute_transparency(stbi__png *z, stbi_uc tc[3], int out_n) +{ + stbi__context *s = z->s; + stbi__uint32 i, pixel_count = s->img_x * s->img_y; + stbi_uc *p = z->out; + + // compute color-based transparency, assuming we've + // already got 255 as the alpha value in the output + STBI_ASSERT(out_n == 2 || out_n == 4); + + if (out_n == 2) { + for (i=0; i < pixel_count; ++i) { + p[1] = (p[0] == tc[0] ? 0 : 255); + p += 2; + } + } else { + for (i=0; i < pixel_count; ++i) { + if (p[0] == tc[0] && p[1] == tc[1] && p[2] == tc[2]) + p[3] = 0; + p += 4; + } + } + return 1; +} + +static int stbi__compute_transparency16(stbi__png *z, stbi__uint16 tc[3], int out_n) +{ + stbi__context *s = z->s; + stbi__uint32 i, pixel_count = s->img_x * s->img_y; + stbi__uint16 *p = (stbi__uint16*) z->out; + + // compute color-based transparency, assuming we've + // already got 65535 as the alpha value in the output + STBI_ASSERT(out_n == 2 || out_n == 4); + + if (out_n == 2) { + for (i = 0; i < pixel_count; ++i) { + p[1] = (p[0] == tc[0] ? 0 : 65535); + p += 2; + } + } else { + for (i = 0; i < pixel_count; ++i) { + if (p[0] == tc[0] && p[1] == tc[1] && p[2] == tc[2]) + p[3] = 0; + p += 4; + } + } + return 1; +} + +static int stbi__expand_png_palette(stbi__png *a, stbi_uc *palette, int len, int pal_img_n) +{ + stbi__uint32 i, pixel_count = a->s->img_x * a->s->img_y; + stbi_uc *p, *temp_out, *orig = a->out; + + p = (stbi_uc *) stbi__malloc_mad2(pixel_count, pal_img_n, 0); + if (p == NULL) return stbi__err("outofmem", "Out of memory"); + + // between here and free(out) below, exitting would leak + temp_out = p; + + if (pal_img_n == 3) { + for (i=0; i < pixel_count; ++i) { + int n = orig[i]*4; + p[0] = palette[n ]; + p[1] = palette[n+1]; + p[2] = palette[n+2]; + p += 3; + } + } else { + for (i=0; i < pixel_count; ++i) { + int n = orig[i]*4; + p[0] = palette[n ]; + p[1] = palette[n+1]; + p[2] = palette[n+2]; + p[3] = palette[n+3]; + p += 4; + } + } + STBI_FREE(a->out); + a->out = temp_out; + + STBI_NOTUSED(len); + + return 1; +} + +static int stbi__unpremultiply_on_load = 0; +static int stbi__de_iphone_flag = 0; + +STBIDEF void stbi_set_unpremultiply_on_load(int flag_true_if_should_unpremultiply) +{ + stbi__unpremultiply_on_load = flag_true_if_should_unpremultiply; +} + +STBIDEF void stbi_convert_iphone_png_to_rgb(int flag_true_if_should_convert) +{ + stbi__de_iphone_flag = flag_true_if_should_convert; +} + +static void stbi__de_iphone(stbi__png *z) +{ + stbi__context *s = z->s; + stbi__uint32 i, pixel_count = s->img_x * s->img_y; + stbi_uc *p = z->out; + + if (s->img_out_n == 3) { // convert bgr to rgb + for (i=0; i < pixel_count; ++i) { + stbi_uc t = p[0]; + p[0] = p[2]; + p[2] = t; + p += 3; + } + } else { + STBI_ASSERT(s->img_out_n == 4); + if (stbi__unpremultiply_on_load) { + // convert bgr to rgb and unpremultiply + for (i=0; i < pixel_count; ++i) { + stbi_uc a = p[3]; + stbi_uc t = p[0]; + if (a) { + stbi_uc half = a / 2; + p[0] = (p[2] * 255 + half) / a; + p[1] = (p[1] * 255 + half) / a; + p[2] = ( t * 255 + half) / a; + } else { + p[0] = p[2]; + p[2] = t; + } + p += 4; + } + } else { + // convert bgr to rgb + for (i=0; i < pixel_count; ++i) { + stbi_uc t = p[0]; + p[0] = p[2]; + p[2] = t; + p += 4; + } + } + } +} + +#define STBI__PNG_TYPE(a,b,c,d) (((unsigned) (a) << 24) + ((unsigned) (b) << 16) + ((unsigned) (c) << 8) + (unsigned) (d)) + +static int stbi__parse_png_file(stbi__png *z, int scan, int req_comp) +{ + stbi_uc palette[1024], pal_img_n=0; + stbi_uc has_trans=0, tc[3]={0}; + stbi__uint16 tc16[3]; + stbi__uint32 ioff=0, idata_limit=0, i, pal_len=0; + int first=1,k,interlace=0, color=0, is_iphone=0; + stbi__context *s = z->s; + + z->expanded = NULL; + z->idata = NULL; + z->out = NULL; + + if (!stbi__check_png_header(s)) return 0; + + if (scan == STBI__SCAN_type) return 1; + + for (;;) { + stbi__pngchunk c = stbi__get_chunk_header(s); + switch (c.type) { + case STBI__PNG_TYPE('C','g','B','I'): + is_iphone = 1; + stbi__skip(s, c.length); + break; + case STBI__PNG_TYPE('I','H','D','R'): { + int comp,filter; + if (!first) return stbi__err("multiple IHDR","Corrupt PNG"); + first = 0; + if (c.length != 13) return stbi__err("bad IHDR len","Corrupt PNG"); + s->img_x = stbi__get32be(s); + s->img_y = stbi__get32be(s); + if (s->img_y > STBI_MAX_DIMENSIONS) return stbi__err("too large","Very large image (corrupt?)"); + if (s->img_x > STBI_MAX_DIMENSIONS) return stbi__err("too large","Very large image (corrupt?)"); + z->depth = stbi__get8(s); if (z->depth != 1 && z->depth != 2 && z->depth != 4 && z->depth != 8 && z->depth != 16) return stbi__err("1/2/4/8/16-bit only","PNG not supported: 1/2/4/8/16-bit only"); + color = stbi__get8(s); if (color > 6) return stbi__err("bad ctype","Corrupt PNG"); + if (color == 3 && z->depth == 16) return stbi__err("bad ctype","Corrupt PNG"); + if (color == 3) pal_img_n = 3; else if (color & 1) return stbi__err("bad ctype","Corrupt PNG"); + comp = stbi__get8(s); if (comp) return stbi__err("bad comp method","Corrupt PNG"); + filter= stbi__get8(s); if (filter) return stbi__err("bad filter method","Corrupt PNG"); + interlace = stbi__get8(s); if (interlace>1) return stbi__err("bad interlace method","Corrupt PNG"); + if (!s->img_x || !s->img_y) return stbi__err("0-pixel image","Corrupt PNG"); + if (!pal_img_n) { + s->img_n = (color & 2 ? 3 : 1) + (color & 4 ? 1 : 0); + if ((1 << 30) / s->img_x / s->img_n < s->img_y) return stbi__err("too large", "Image too large to decode"); + if (scan == STBI__SCAN_header) return 1; + } else { + // if paletted, then pal_n is our final components, and + // img_n is # components to decompress/filter. + s->img_n = 1; + if ((1 << 30) / s->img_x / 4 < s->img_y) return stbi__err("too large","Corrupt PNG"); + // if SCAN_header, have to scan to see if we have a tRNS + } + break; + } + + case STBI__PNG_TYPE('P','L','T','E'): { + if (first) return stbi__err("first not IHDR", "Corrupt PNG"); + if (c.length > 256*3) return stbi__err("invalid PLTE","Corrupt PNG"); + pal_len = c.length / 3; + if (pal_len * 3 != c.length) return stbi__err("invalid PLTE","Corrupt PNG"); + for (i=0; i < pal_len; ++i) { + palette[i*4+0] = stbi__get8(s); + palette[i*4+1] = stbi__get8(s); + palette[i*4+2] = stbi__get8(s); + palette[i*4+3] = 255; + } + break; + } + + case STBI__PNG_TYPE('t','R','N','S'): { + if (first) return stbi__err("first not IHDR", "Corrupt PNG"); + if (z->idata) return stbi__err("tRNS after IDAT","Corrupt PNG"); + if (pal_img_n) { + if (scan == STBI__SCAN_header) { s->img_n = 4; return 1; } + if (pal_len == 0) return stbi__err("tRNS before PLTE","Corrupt PNG"); + if (c.length > pal_len) return stbi__err("bad tRNS len","Corrupt PNG"); + pal_img_n = 4; + for (i=0; i < c.length; ++i) + palette[i*4+3] = stbi__get8(s); + } else { + if (!(s->img_n & 1)) return stbi__err("tRNS with alpha","Corrupt PNG"); + if (c.length != (stbi__uint32) s->img_n*2) return stbi__err("bad tRNS len","Corrupt PNG"); + has_trans = 1; + if (z->depth == 16) { + for (k = 0; k < s->img_n; ++k) tc16[k] = (stbi__uint16)stbi__get16be(s); // copy the values as-is + } else { + for (k = 0; k < s->img_n; ++k) tc[k] = (stbi_uc)(stbi__get16be(s) & 255) * stbi__depth_scale_table[z->depth]; // non 8-bit images will be larger + } + } + break; + } + + case STBI__PNG_TYPE('I','D','A','T'): { + if (first) return stbi__err("first not IHDR", "Corrupt PNG"); + if (pal_img_n && !pal_len) return stbi__err("no PLTE","Corrupt PNG"); + if (scan == STBI__SCAN_header) { s->img_n = pal_img_n; return 1; } + if ((int)(ioff + c.length) < (int)ioff) return 0; + if (ioff + c.length > idata_limit) { + stbi__uint32 idata_limit_old = idata_limit; + stbi_uc *p; + if (idata_limit == 0) idata_limit = c.length > 4096 ? c.length : 4096; + while (ioff + c.length > idata_limit) + idata_limit *= 2; + STBI_NOTUSED(idata_limit_old); + p = (stbi_uc *) STBI_REALLOC_SIZED(z->idata, idata_limit_old, idata_limit); if (p == NULL) return stbi__err("outofmem", "Out of memory"); + z->idata = p; + } + if (!stbi__getn(s, z->idata+ioff,c.length)) return stbi__err("outofdata","Corrupt PNG"); + ioff += c.length; + break; + } + + case STBI__PNG_TYPE('I','E','N','D'): { + stbi__uint32 raw_len, bpl; + if (first) return stbi__err("first not IHDR", "Corrupt PNG"); + if (scan != STBI__SCAN_load) return 1; + if (z->idata == NULL) return stbi__err("no IDAT","Corrupt PNG"); + // initial guess for decoded data size to avoid unnecessary reallocs + bpl = (s->img_x * z->depth + 7) / 8; // bytes per line, per component + raw_len = bpl * s->img_y * s->img_n /* pixels */ + s->img_y /* filter mode per row */; + z->expanded = (stbi_uc *) stbi_zlib_decode_malloc_guesssize_headerflag((char *) z->idata, ioff, raw_len, (int *) &raw_len, !is_iphone); + if (z->expanded == NULL) return 0; // zlib should set error + STBI_FREE(z->idata); z->idata = NULL; + if ((req_comp == s->img_n+1 && req_comp != 3 && !pal_img_n) || has_trans) + s->img_out_n = s->img_n+1; + else + s->img_out_n = s->img_n; + if (!stbi__create_png_image(z, z->expanded, raw_len, s->img_out_n, z->depth, color, interlace)) return 0; + if (has_trans) { + if (z->depth == 16) { + if (!stbi__compute_transparency16(z, tc16, s->img_out_n)) return 0; + } else { + if (!stbi__compute_transparency(z, tc, s->img_out_n)) return 0; + } + } + if (is_iphone && stbi__de_iphone_flag && s->img_out_n > 2) + stbi__de_iphone(z); + if (pal_img_n) { + // pal_img_n == 3 or 4 + s->img_n = pal_img_n; // record the actual colors we had + s->img_out_n = pal_img_n; + if (req_comp >= 3) s->img_out_n = req_comp; + if (!stbi__expand_png_palette(z, palette, pal_len, s->img_out_n)) + return 0; + } else if (has_trans) { + // non-paletted image with tRNS -> source image has (constant) alpha + ++s->img_n; + } + STBI_FREE(z->expanded); z->expanded = NULL; + // end of PNG chunk, read and skip CRC + stbi__get32be(s); + return 1; + } + + default: + // if critical, fail + if (first) return stbi__err("first not IHDR", "Corrupt PNG"); + if ((c.type & (1 << 29)) == 0) { + #ifndef STBI_NO_FAILURE_STRINGS + // not threadsafe + static char invalid_chunk[] = "XXXX PNG chunk not known"; + invalid_chunk[0] = STBI__BYTECAST(c.type >> 24); + invalid_chunk[1] = STBI__BYTECAST(c.type >> 16); + invalid_chunk[2] = STBI__BYTECAST(c.type >> 8); + invalid_chunk[3] = STBI__BYTECAST(c.type >> 0); + #endif + return stbi__err(invalid_chunk, "PNG not supported: unknown PNG chunk type"); + } + stbi__skip(s, c.length); + break; + } + // end of PNG chunk, read and skip CRC + stbi__get32be(s); + } +} + +static void *stbi__do_png(stbi__png *p, int *x, int *y, int *n, int req_comp, stbi__result_info *ri) +{ + void *result=NULL; + if (req_comp < 0 || req_comp > 4) return stbi__errpuc("bad req_comp", "Internal error"); + if (stbi__parse_png_file(p, STBI__SCAN_load, req_comp)) { + if (p->depth <= 8) + ri->bits_per_channel = 8; + else if (p->depth == 16) + ri->bits_per_channel = 16; + else + return stbi__errpuc("bad bits_per_channel", "PNG not supported: unsupported color depth"); + result = p->out; + p->out = NULL; + if (req_comp && req_comp != p->s->img_out_n) { + if (ri->bits_per_channel == 8) + result = stbi__convert_format((unsigned char *) result, p->s->img_out_n, req_comp, p->s->img_x, p->s->img_y); + else + result = stbi__convert_format16((stbi__uint16 *) result, p->s->img_out_n, req_comp, p->s->img_x, p->s->img_y); + p->s->img_out_n = req_comp; + if (result == NULL) return result; + } + *x = p->s->img_x; + *y = p->s->img_y; + if (n) *n = p->s->img_n; + } + STBI_FREE(p->out); p->out = NULL; + STBI_FREE(p->expanded); p->expanded = NULL; + STBI_FREE(p->idata); p->idata = NULL; + + return result; +} + +static void *stbi__png_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +{ + stbi__png p; + p.s = s; + return stbi__do_png(&p, x,y,comp,req_comp, ri); +} + +static int stbi__png_test(stbi__context *s) +{ + int r; + r = stbi__check_png_header(s); + stbi__rewind(s); + return r; +} + +static int stbi__png_info_raw(stbi__png *p, int *x, int *y, int *comp) +{ + if (!stbi__parse_png_file(p, STBI__SCAN_header, 0)) { + stbi__rewind( p->s ); + return 0; + } + if (x) *x = p->s->img_x; + if (y) *y = p->s->img_y; + if (comp) *comp = p->s->img_n; + return 1; +} + +static int stbi__png_info(stbi__context *s, int *x, int *y, int *comp) +{ + stbi__png p; + p.s = s; + return stbi__png_info_raw(&p, x, y, comp); +} + +static int stbi__png_is16(stbi__context *s) +{ + stbi__png p; + p.s = s; + if (!stbi__png_info_raw(&p, NULL, NULL, NULL)) + return 0; + if (p.depth != 16) { + stbi__rewind(p.s); + return 0; + } + return 1; +} +#endif + +// Microsoft/Windows BMP image + +#ifndef STBI_NO_BMP +static int stbi__bmp_test_raw(stbi__context *s) +{ + int r; + int sz; + if (stbi__get8(s) != 'B') return 0; + if (stbi__get8(s) != 'M') return 0; + stbi__get32le(s); // discard filesize + stbi__get16le(s); // discard reserved + stbi__get16le(s); // discard reserved + stbi__get32le(s); // discard data offset + sz = stbi__get32le(s); + r = (sz == 12 || sz == 40 || sz == 56 || sz == 108 || sz == 124); + return r; +} + +static int stbi__bmp_test(stbi__context *s) +{ + int r = stbi__bmp_test_raw(s); + stbi__rewind(s); + return r; +} + + +// returns 0..31 for the highest set bit +static int stbi__high_bit(unsigned int z) +{ + int n=0; + if (z == 0) return -1; + if (z >= 0x10000) { n += 16; z >>= 16; } + if (z >= 0x00100) { n += 8; z >>= 8; } + if (z >= 0x00010) { n += 4; z >>= 4; } + if (z >= 0x00004) { n += 2; z >>= 2; } + if (z >= 0x00002) { n += 1;/* >>= 1;*/ } + return n; +} + +static int stbi__bitcount(unsigned int a) +{ + a = (a & 0x55555555) + ((a >> 1) & 0x55555555); // max 2 + a = (a & 0x33333333) + ((a >> 2) & 0x33333333); // max 4 + a = (a + (a >> 4)) & 0x0f0f0f0f; // max 8 per 4, now 8 bits + a = (a + (a >> 8)); // max 16 per 8 bits + a = (a + (a >> 16)); // max 32 per 8 bits + return a & 0xff; +} + +// extract an arbitrarily-aligned N-bit value (N=bits) +// from v, and then make it 8-bits long and fractionally +// extend it to full full range. +static int stbi__shiftsigned(unsigned int v, int shift, int bits) +{ + static unsigned int mul_table[9] = { + 0, + 0xff/*0b11111111*/, 0x55/*0b01010101*/, 0x49/*0b01001001*/, 0x11/*0b00010001*/, + 0x21/*0b00100001*/, 0x41/*0b01000001*/, 0x81/*0b10000001*/, 0x01/*0b00000001*/, + }; + static unsigned int shift_table[9] = { + 0, 0,0,1,0,2,4,6,0, + }; + if (shift < 0) + v <<= -shift; + else + v >>= shift; + STBI_ASSERT(v < 256); + v >>= (8-bits); + STBI_ASSERT(bits >= 0 && bits <= 8); + return (int) ((unsigned) v * mul_table[bits]) >> shift_table[bits]; +} + +typedef struct +{ + int bpp, offset, hsz; + unsigned int mr,mg,mb,ma, all_a; + int extra_read; +} stbi__bmp_data; + +static void *stbi__bmp_parse_header(stbi__context *s, stbi__bmp_data *info) +{ + int hsz; + if (stbi__get8(s) != 'B' || stbi__get8(s) != 'M') return stbi__errpuc("not BMP", "Corrupt BMP"); + stbi__get32le(s); // discard filesize + stbi__get16le(s); // discard reserved + stbi__get16le(s); // discard reserved + info->offset = stbi__get32le(s); + info->hsz = hsz = stbi__get32le(s); + info->mr = info->mg = info->mb = info->ma = 0; + info->extra_read = 14; + + if (info->offset < 0) return stbi__errpuc("bad BMP", "bad BMP"); + + if (hsz != 12 && hsz != 40 && hsz != 56 && hsz != 108 && hsz != 124) return stbi__errpuc("unknown BMP", "BMP type not supported: unknown"); + if (hsz == 12) { + s->img_x = stbi__get16le(s); + s->img_y = stbi__get16le(s); + } else { + s->img_x = stbi__get32le(s); + s->img_y = stbi__get32le(s); + } + if (stbi__get16le(s) != 1) return stbi__errpuc("bad BMP", "bad BMP"); + info->bpp = stbi__get16le(s); + if (hsz != 12) { + int compress = stbi__get32le(s); + if (compress == 1 || compress == 2) return stbi__errpuc("BMP RLE", "BMP type not supported: RLE"); + stbi__get32le(s); // discard sizeof + stbi__get32le(s); // discard hres + stbi__get32le(s); // discard vres + stbi__get32le(s); // discard colorsused + stbi__get32le(s); // discard max important + if (hsz == 40 || hsz == 56) { + if (hsz == 56) { + stbi__get32le(s); + stbi__get32le(s); + stbi__get32le(s); + stbi__get32le(s); + } + if (info->bpp == 16 || info->bpp == 32) { + if (compress == 0) { + if (info->bpp == 32) { + info->mr = 0xffu << 16; + info->mg = 0xffu << 8; + info->mb = 0xffu << 0; + info->ma = 0xffu << 24; + info->all_a = 0; // if all_a is 0 at end, then we loaded alpha channel but it was all 0 + } else { + info->mr = 31u << 10; + info->mg = 31u << 5; + info->mb = 31u << 0; + } + } else if (compress == 3) { + info->mr = stbi__get32le(s); + info->mg = stbi__get32le(s); + info->mb = stbi__get32le(s); + info->extra_read += 12; + // not documented, but generated by photoshop and handled by mspaint + if (info->mr == info->mg && info->mg == info->mb) { + // ?!?!? + return stbi__errpuc("bad BMP", "bad BMP"); + } + } else + return stbi__errpuc("bad BMP", "bad BMP"); + } + } else { + int i; + if (hsz != 108 && hsz != 124) + return stbi__errpuc("bad BMP", "bad BMP"); + info->mr = stbi__get32le(s); + info->mg = stbi__get32le(s); + info->mb = stbi__get32le(s); + info->ma = stbi__get32le(s); + stbi__get32le(s); // discard color space + for (i=0; i < 12; ++i) + stbi__get32le(s); // discard color space parameters + if (hsz == 124) { + stbi__get32le(s); // discard rendering intent + stbi__get32le(s); // discard offset of profile data + stbi__get32le(s); // discard size of profile data + stbi__get32le(s); // discard reserved + } + } + } + return (void *) 1; +} + + +static void *stbi__bmp_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +{ + stbi_uc *out; + unsigned int mr=0,mg=0,mb=0,ma=0, all_a; + stbi_uc pal[256][4]; + int psize=0,i,j,width; + int flip_vertically, pad, target; + stbi__bmp_data info; + STBI_NOTUSED(ri); + + info.all_a = 255; + if (stbi__bmp_parse_header(s, &info) == NULL) + return NULL; // error code already set + + flip_vertically = ((int) s->img_y) > 0; + s->img_y = abs((int) s->img_y); + + if (s->img_y > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + if (s->img_x > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + + mr = info.mr; + mg = info.mg; + mb = info.mb; + ma = info.ma; + all_a = info.all_a; + + if (info.hsz == 12) { + if (info.bpp < 24) + psize = (info.offset - info.extra_read - 24) / 3; + } else { + if (info.bpp < 16) + psize = (info.offset - info.extra_read - info.hsz) >> 2; + } + if (psize == 0) { + STBI_ASSERT(info.offset == s->callback_already_read + (int) (s->img_buffer - s->img_buffer_original)); + if (info.offset != s->callback_already_read + (s->img_buffer - s->buffer_start)) { + return stbi__errpuc("bad offset", "Corrupt BMP"); + } + } + + if (info.bpp == 24 && ma == 0xff000000) + s->img_n = 3; + else + s->img_n = ma ? 4 : 3; + if (req_comp && req_comp >= 3) // we can directly decode 3 or 4 + target = req_comp; + else + target = s->img_n; // if they want monochrome, we'll post-convert + + // sanity-check size + if (!stbi__mad3sizes_valid(target, s->img_x, s->img_y, 0)) + return stbi__errpuc("too large", "Corrupt BMP"); + + out = (stbi_uc *) stbi__malloc_mad3(target, s->img_x, s->img_y, 0); + if (!out) return stbi__errpuc("outofmem", "Out of memory"); + if (info.bpp < 16) { + int z=0; + if (psize == 0 || psize > 256) { STBI_FREE(out); return stbi__errpuc("invalid", "Corrupt BMP"); } + for (i=0; i < psize; ++i) { + pal[i][2] = stbi__get8(s); + pal[i][1] = stbi__get8(s); + pal[i][0] = stbi__get8(s); + if (info.hsz != 12) stbi__get8(s); + pal[i][3] = 255; + } + stbi__skip(s, info.offset - info.extra_read - info.hsz - psize * (info.hsz == 12 ? 3 : 4)); + if (info.bpp == 1) width = (s->img_x + 7) >> 3; + else if (info.bpp == 4) width = (s->img_x + 1) >> 1; + else if (info.bpp == 8) width = s->img_x; + else { STBI_FREE(out); return stbi__errpuc("bad bpp", "Corrupt BMP"); } + pad = (-width)&3; + if (info.bpp == 1) { + for (j=0; j < (int) s->img_y; ++j) { + int bit_offset = 7, v = stbi__get8(s); + for (i=0; i < (int) s->img_x; ++i) { + int color = (v>>bit_offset)&0x1; + out[z++] = pal[color][0]; + out[z++] = pal[color][1]; + out[z++] = pal[color][2]; + if (target == 4) out[z++] = 255; + if (i+1 == (int) s->img_x) break; + if((--bit_offset) < 0) { + bit_offset = 7; + v = stbi__get8(s); + } + } + stbi__skip(s, pad); + } + } else { + for (j=0; j < (int) s->img_y; ++j) { + for (i=0; i < (int) s->img_x; i += 2) { + int v=stbi__get8(s),v2=0; + if (info.bpp == 4) { + v2 = v & 15; + v >>= 4; + } + out[z++] = pal[v][0]; + out[z++] = pal[v][1]; + out[z++] = pal[v][2]; + if (target == 4) out[z++] = 255; + if (i+1 == (int) s->img_x) break; + v = (info.bpp == 8) ? stbi__get8(s) : v2; + out[z++] = pal[v][0]; + out[z++] = pal[v][1]; + out[z++] = pal[v][2]; + if (target == 4) out[z++] = 255; + } + stbi__skip(s, pad); + } + } + } else { + int rshift=0,gshift=0,bshift=0,ashift=0,rcount=0,gcount=0,bcount=0,acount=0; + int z = 0; + int easy=0; + stbi__skip(s, info.offset - info.extra_read - info.hsz); + if (info.bpp == 24) width = 3 * s->img_x; + else if (info.bpp == 16) width = 2*s->img_x; + else /* bpp = 32 and pad = 0 */ width=0; + pad = (-width) & 3; + if (info.bpp == 24) { + easy = 1; + } else if (info.bpp == 32) { + if (mb == 0xff && mg == 0xff00 && mr == 0x00ff0000 && ma == 0xff000000) + easy = 2; + } + if (!easy) { + if (!mr || !mg || !mb) { STBI_FREE(out); return stbi__errpuc("bad masks", "Corrupt BMP"); } + // right shift amt to put high bit in position #7 + rshift = stbi__high_bit(mr)-7; rcount = stbi__bitcount(mr); + gshift = stbi__high_bit(mg)-7; gcount = stbi__bitcount(mg); + bshift = stbi__high_bit(mb)-7; bcount = stbi__bitcount(mb); + ashift = stbi__high_bit(ma)-7; acount = stbi__bitcount(ma); + if (rcount > 8 || gcount > 8 || bcount > 8 || acount > 8) { STBI_FREE(out); return stbi__errpuc("bad masks", "Corrupt BMP"); } + } + for (j=0; j < (int) s->img_y; ++j) { + if (easy) { + for (i=0; i < (int) s->img_x; ++i) { + unsigned char a; + out[z+2] = stbi__get8(s); + out[z+1] = stbi__get8(s); + out[z+0] = stbi__get8(s); + z += 3; + a = (easy == 2 ? stbi__get8(s) : 255); + all_a |= a; + if (target == 4) out[z++] = a; + } + } else { + int bpp = info.bpp; + for (i=0; i < (int) s->img_x; ++i) { + stbi__uint32 v = (bpp == 16 ? (stbi__uint32) stbi__get16le(s) : stbi__get32le(s)); + unsigned int a; + out[z++] = STBI__BYTECAST(stbi__shiftsigned(v & mr, rshift, rcount)); + out[z++] = STBI__BYTECAST(stbi__shiftsigned(v & mg, gshift, gcount)); + out[z++] = STBI__BYTECAST(stbi__shiftsigned(v & mb, bshift, bcount)); + a = (ma ? stbi__shiftsigned(v & ma, ashift, acount) : 255); + all_a |= a; + if (target == 4) out[z++] = STBI__BYTECAST(a); + } + } + stbi__skip(s, pad); + } + } + + // if alpha channel is all 0s, replace with all 255s + if (target == 4 && all_a == 0) + for (i=4*s->img_x*s->img_y-1; i >= 0; i -= 4) + out[i] = 255; + + if (flip_vertically) { + stbi_uc t; + for (j=0; j < (int) s->img_y>>1; ++j) { + stbi_uc *p1 = out + j *s->img_x*target; + stbi_uc *p2 = out + (s->img_y-1-j)*s->img_x*target; + for (i=0; i < (int) s->img_x*target; ++i) { + t = p1[i]; p1[i] = p2[i]; p2[i] = t; + } + } + } + + if (req_comp && req_comp != target) { + out = stbi__convert_format(out, target, req_comp, s->img_x, s->img_y); + if (out == NULL) return out; // stbi__convert_format frees input on failure + } + + *x = s->img_x; + *y = s->img_y; + if (comp) *comp = s->img_n; + return out; +} +#endif + +// Targa Truevision - TGA +// by Jonathan Dummer +#ifndef STBI_NO_TGA +// returns STBI_rgb or whatever, 0 on error +static int stbi__tga_get_comp(int bits_per_pixel, int is_grey, int* is_rgb16) +{ + // only RGB or RGBA (incl. 16bit) or grey allowed + if (is_rgb16) *is_rgb16 = 0; + switch(bits_per_pixel) { + case 8: return STBI_grey; + case 16: if(is_grey) return STBI_grey_alpha; + // fallthrough + case 15: if(is_rgb16) *is_rgb16 = 1; + return STBI_rgb; + case 24: // fallthrough + case 32: return bits_per_pixel/8; + default: return 0; + } +} + +static int stbi__tga_info(stbi__context *s, int *x, int *y, int *comp) +{ + int tga_w, tga_h, tga_comp, tga_image_type, tga_bits_per_pixel, tga_colormap_bpp; + int sz, tga_colormap_type; + stbi__get8(s); // discard Offset + tga_colormap_type = stbi__get8(s); // colormap type + if( tga_colormap_type > 1 ) { + stbi__rewind(s); + return 0; // only RGB or indexed allowed + } + tga_image_type = stbi__get8(s); // image type + if ( tga_colormap_type == 1 ) { // colormapped (paletted) image + if (tga_image_type != 1 && tga_image_type != 9) { + stbi__rewind(s); + return 0; + } + stbi__skip(s,4); // skip index of first colormap entry and number of entries + sz = stbi__get8(s); // check bits per palette color entry + if ( (sz != 8) && (sz != 15) && (sz != 16) && (sz != 24) && (sz != 32) ) { + stbi__rewind(s); + return 0; + } + stbi__skip(s,4); // skip image x and y origin + tga_colormap_bpp = sz; + } else { // "normal" image w/o colormap - only RGB or grey allowed, +/- RLE + if ( (tga_image_type != 2) && (tga_image_type != 3) && (tga_image_type != 10) && (tga_image_type != 11) ) { + stbi__rewind(s); + return 0; // only RGB or grey allowed, +/- RLE + } + stbi__skip(s,9); // skip colormap specification and image x/y origin + tga_colormap_bpp = 0; + } + tga_w = stbi__get16le(s); + if( tga_w < 1 ) { + stbi__rewind(s); + return 0; // test width + } + tga_h = stbi__get16le(s); + if( tga_h < 1 ) { + stbi__rewind(s); + return 0; // test height + } + tga_bits_per_pixel = stbi__get8(s); // bits per pixel + stbi__get8(s); // ignore alpha bits + if (tga_colormap_bpp != 0) { + if((tga_bits_per_pixel != 8) && (tga_bits_per_pixel != 16)) { + // when using a colormap, tga_bits_per_pixel is the size of the indexes + // I don't think anything but 8 or 16bit indexes makes sense + stbi__rewind(s); + return 0; + } + tga_comp = stbi__tga_get_comp(tga_colormap_bpp, 0, NULL); + } else { + tga_comp = stbi__tga_get_comp(tga_bits_per_pixel, (tga_image_type == 3) || (tga_image_type == 11), NULL); + } + if(!tga_comp) { + stbi__rewind(s); + return 0; + } + if (x) *x = tga_w; + if (y) *y = tga_h; + if (comp) *comp = tga_comp; + return 1; // seems to have passed everything +} + +static int stbi__tga_test(stbi__context *s) +{ + int res = 0; + int sz, tga_color_type; + stbi__get8(s); // discard Offset + tga_color_type = stbi__get8(s); // color type + if ( tga_color_type > 1 ) goto errorEnd; // only RGB or indexed allowed + sz = stbi__get8(s); // image type + if ( tga_color_type == 1 ) { // colormapped (paletted) image + if (sz != 1 && sz != 9) goto errorEnd; // colortype 1 demands image type 1 or 9 + stbi__skip(s,4); // skip index of first colormap entry and number of entries + sz = stbi__get8(s); // check bits per palette color entry + if ( (sz != 8) && (sz != 15) && (sz != 16) && (sz != 24) && (sz != 32) ) goto errorEnd; + stbi__skip(s,4); // skip image x and y origin + } else { // "normal" image w/o colormap + if ( (sz != 2) && (sz != 3) && (sz != 10) && (sz != 11) ) goto errorEnd; // only RGB or grey allowed, +/- RLE + stbi__skip(s,9); // skip colormap specification and image x/y origin + } + if ( stbi__get16le(s) < 1 ) goto errorEnd; // test width + if ( stbi__get16le(s) < 1 ) goto errorEnd; // test height + sz = stbi__get8(s); // bits per pixel + if ( (tga_color_type == 1) && (sz != 8) && (sz != 16) ) goto errorEnd; // for colormapped images, bpp is size of an index + if ( (sz != 8) && (sz != 15) && (sz != 16) && (sz != 24) && (sz != 32) ) goto errorEnd; + + res = 1; // if we got this far, everything's good and we can return 1 instead of 0 + +errorEnd: + stbi__rewind(s); + return res; +} + +// read 16bit value and convert to 24bit RGB +static void stbi__tga_read_rgb16(stbi__context *s, stbi_uc* out) +{ + stbi__uint16 px = (stbi__uint16)stbi__get16le(s); + stbi__uint16 fiveBitMask = 31; + // we have 3 channels with 5bits each + int r = (px >> 10) & fiveBitMask; + int g = (px >> 5) & fiveBitMask; + int b = px & fiveBitMask; + // Note that this saves the data in RGB(A) order, so it doesn't need to be swapped later + out[0] = (stbi_uc)((r * 255)/31); + out[1] = (stbi_uc)((g * 255)/31); + out[2] = (stbi_uc)((b * 255)/31); + + // some people claim that the most significant bit might be used for alpha + // (possibly if an alpha-bit is set in the "image descriptor byte") + // but that only made 16bit test images completely translucent.. + // so let's treat all 15 and 16bit TGAs as RGB with no alpha. +} + +static void *stbi__tga_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +{ + // read in the TGA header stuff + int tga_offset = stbi__get8(s); + int tga_indexed = stbi__get8(s); + int tga_image_type = stbi__get8(s); + int tga_is_RLE = 0; + int tga_palette_start = stbi__get16le(s); + int tga_palette_len = stbi__get16le(s); + int tga_palette_bits = stbi__get8(s); + int tga_x_origin = stbi__get16le(s); + int tga_y_origin = stbi__get16le(s); + int tga_width = stbi__get16le(s); + int tga_height = stbi__get16le(s); + int tga_bits_per_pixel = stbi__get8(s); + int tga_comp, tga_rgb16=0; + int tga_inverted = stbi__get8(s); + // int tga_alpha_bits = tga_inverted & 15; // the 4 lowest bits - unused (useless?) + // image data + unsigned char *tga_data; + unsigned char *tga_palette = NULL; + int i, j; + unsigned char raw_data[4] = {0}; + int RLE_count = 0; + int RLE_repeating = 0; + int read_next_pixel = 1; + STBI_NOTUSED(ri); + STBI_NOTUSED(tga_x_origin); // @TODO + STBI_NOTUSED(tga_y_origin); // @TODO + + if (tga_height > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + if (tga_width > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + + // do a tiny bit of precessing + if ( tga_image_type >= 8 ) + { + tga_image_type -= 8; + tga_is_RLE = 1; + } + tga_inverted = 1 - ((tga_inverted >> 5) & 1); + + // If I'm paletted, then I'll use the number of bits from the palette + if ( tga_indexed ) tga_comp = stbi__tga_get_comp(tga_palette_bits, 0, &tga_rgb16); + else tga_comp = stbi__tga_get_comp(tga_bits_per_pixel, (tga_image_type == 3), &tga_rgb16); + + if(!tga_comp) // shouldn't really happen, stbi__tga_test() should have ensured basic consistency + return stbi__errpuc("bad format", "Can't find out TGA pixelformat"); + + // tga info + *x = tga_width; + *y = tga_height; + if (comp) *comp = tga_comp; + + if (!stbi__mad3sizes_valid(tga_width, tga_height, tga_comp, 0)) + return stbi__errpuc("too large", "Corrupt TGA"); + + tga_data = (unsigned char*)stbi__malloc_mad3(tga_width, tga_height, tga_comp, 0); + if (!tga_data) return stbi__errpuc("outofmem", "Out of memory"); + + // skip to the data's starting position (offset usually = 0) + stbi__skip(s, tga_offset ); + + if ( !tga_indexed && !tga_is_RLE && !tga_rgb16 ) { + for (i=0; i < tga_height; ++i) { + int row = tga_inverted ? tga_height -i - 1 : i; + stbi_uc *tga_row = tga_data + row*tga_width*tga_comp; + stbi__getn(s, tga_row, tga_width * tga_comp); + } + } else { + // do I need to load a palette? + if ( tga_indexed) + { + if (tga_palette_len == 0) { /* you have to have at least one entry! */ + STBI_FREE(tga_data); + return stbi__errpuc("bad palette", "Corrupt TGA"); + } + + // any data to skip? (offset usually = 0) + stbi__skip(s, tga_palette_start ); + // load the palette + tga_palette = (unsigned char*)stbi__malloc_mad2(tga_palette_len, tga_comp, 0); + if (!tga_palette) { + STBI_FREE(tga_data); + return stbi__errpuc("outofmem", "Out of memory"); + } + if (tga_rgb16) { + stbi_uc *pal_entry = tga_palette; + STBI_ASSERT(tga_comp == STBI_rgb); + for (i=0; i < tga_palette_len; ++i) { + stbi__tga_read_rgb16(s, pal_entry); + pal_entry += tga_comp; + } + } else if (!stbi__getn(s, tga_palette, tga_palette_len * tga_comp)) { + STBI_FREE(tga_data); + STBI_FREE(tga_palette); + return stbi__errpuc("bad palette", "Corrupt TGA"); + } + } + // load the data + for (i=0; i < tga_width * tga_height; ++i) + { + // if I'm in RLE mode, do I need to get a RLE stbi__pngchunk? + if ( tga_is_RLE ) + { + if ( RLE_count == 0 ) + { + // yep, get the next byte as a RLE command + int RLE_cmd = stbi__get8(s); + RLE_count = 1 + (RLE_cmd & 127); + RLE_repeating = RLE_cmd >> 7; + read_next_pixel = 1; + } else if ( !RLE_repeating ) + { + read_next_pixel = 1; + } + } else + { + read_next_pixel = 1; + } + // OK, if I need to read a pixel, do it now + if ( read_next_pixel ) + { + // load however much data we did have + if ( tga_indexed ) + { + // read in index, then perform the lookup + int pal_idx = (tga_bits_per_pixel == 8) ? stbi__get8(s) : stbi__get16le(s); + if ( pal_idx >= tga_palette_len ) { + // invalid index + pal_idx = 0; + } + pal_idx *= tga_comp; + for (j = 0; j < tga_comp; ++j) { + raw_data[j] = tga_palette[pal_idx+j]; + } + } else if(tga_rgb16) { + STBI_ASSERT(tga_comp == STBI_rgb); + stbi__tga_read_rgb16(s, raw_data); + } else { + // read in the data raw + for (j = 0; j < tga_comp; ++j) { + raw_data[j] = stbi__get8(s); + } + } + // clear the reading flag for the next pixel + read_next_pixel = 0; + } // end of reading a pixel + + // copy data + for (j = 0; j < tga_comp; ++j) + tga_data[i*tga_comp+j] = raw_data[j]; + + // in case we're in RLE mode, keep counting down + --RLE_count; + } + // do I need to invert the image? + if ( tga_inverted ) + { + for (j = 0; j*2 < tga_height; ++j) + { + int index1 = j * tga_width * tga_comp; + int index2 = (tga_height - 1 - j) * tga_width * tga_comp; + for (i = tga_width * tga_comp; i > 0; --i) + { + unsigned char temp = tga_data[index1]; + tga_data[index1] = tga_data[index2]; + tga_data[index2] = temp; + ++index1; + ++index2; + } + } + } + // clear my palette, if I had one + if ( tga_palette != NULL ) + { + STBI_FREE( tga_palette ); + } + } + + // swap RGB - if the source data was RGB16, it already is in the right order + if (tga_comp >= 3 && !tga_rgb16) + { + unsigned char* tga_pixel = tga_data; + for (i=0; i < tga_width * tga_height; ++i) + { + unsigned char temp = tga_pixel[0]; + tga_pixel[0] = tga_pixel[2]; + tga_pixel[2] = temp; + tga_pixel += tga_comp; + } + } + + // convert to target component count + if (req_comp && req_comp != tga_comp) + tga_data = stbi__convert_format(tga_data, tga_comp, req_comp, tga_width, tga_height); + + // the things I do to get rid of an error message, and yet keep + // Microsoft's C compilers happy... [8^( + tga_palette_start = tga_palette_len = tga_palette_bits = + tga_x_origin = tga_y_origin = 0; + STBI_NOTUSED(tga_palette_start); + // OK, done + return tga_data; +} +#endif + +// ************************************************************************************************* +// Photoshop PSD loader -- PD by Thatcher Ulrich, integration by Nicolas Schulz, tweaked by STB + +#ifndef STBI_NO_PSD +static int stbi__psd_test(stbi__context *s) +{ + int r = (stbi__get32be(s) == 0x38425053); + stbi__rewind(s); + return r; +} + +static int stbi__psd_decode_rle(stbi__context *s, stbi_uc *p, int pixelCount) +{ + int count, nleft, len; + + count = 0; + while ((nleft = pixelCount - count) > 0) { + len = stbi__get8(s); + if (len == 128) { + // No-op. + } else if (len < 128) { + // Copy next len+1 bytes literally. + len++; + if (len > nleft) return 0; // corrupt data + count += len; + while (len) { + *p = stbi__get8(s); + p += 4; + len--; + } + } else if (len > 128) { + stbi_uc val; + // Next -len+1 bytes in the dest are replicated from next source byte. + // (Interpret len as a negative 8-bit int.) + len = 257 - len; + if (len > nleft) return 0; // corrupt data + val = stbi__get8(s); + count += len; + while (len) { + *p = val; + p += 4; + len--; + } + } + } + + return 1; +} + +static void *stbi__psd_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri, int bpc) +{ + int pixelCount; + int channelCount, compression; + int channel, i; + int bitdepth; + int w,h; + stbi_uc *out; + STBI_NOTUSED(ri); + + // Check identifier + if (stbi__get32be(s) != 0x38425053) // "8BPS" + return stbi__errpuc("not PSD", "Corrupt PSD image"); + + // Check file type version. + if (stbi__get16be(s) != 1) + return stbi__errpuc("wrong version", "Unsupported version of PSD image"); + + // Skip 6 reserved bytes. + stbi__skip(s, 6 ); + + // Read the number of channels (R, G, B, A, etc). + channelCount = stbi__get16be(s); + if (channelCount < 0 || channelCount > 16) + return stbi__errpuc("wrong channel count", "Unsupported number of channels in PSD image"); + + // Read the rows and columns of the image. + h = stbi__get32be(s); + w = stbi__get32be(s); + + if (h > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + if (w > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + + // Make sure the depth is 8 bits. + bitdepth = stbi__get16be(s); + if (bitdepth != 8 && bitdepth != 16) + return stbi__errpuc("unsupported bit depth", "PSD bit depth is not 8 or 16 bit"); + + // Make sure the color mode is RGB. + // Valid options are: + // 0: Bitmap + // 1: Grayscale + // 2: Indexed color + // 3: RGB color + // 4: CMYK color + // 7: Multichannel + // 8: Duotone + // 9: Lab color + if (stbi__get16be(s) != 3) + return stbi__errpuc("wrong color format", "PSD is not in RGB color format"); + + // Skip the Mode Data. (It's the palette for indexed color; other info for other modes.) + stbi__skip(s,stbi__get32be(s) ); + + // Skip the image resources. (resolution, pen tool paths, etc) + stbi__skip(s, stbi__get32be(s) ); + + // Skip the reserved data. + stbi__skip(s, stbi__get32be(s) ); + + // Find out if the data is compressed. + // Known values: + // 0: no compression + // 1: RLE compressed + compression = stbi__get16be(s); + if (compression > 1) + return stbi__errpuc("bad compression", "PSD has an unknown compression format"); + + // Check size + if (!stbi__mad3sizes_valid(4, w, h, 0)) + return stbi__errpuc("too large", "Corrupt PSD"); + + // Create the destination image. + + if (!compression && bitdepth == 16 && bpc == 16) { + out = (stbi_uc *) stbi__malloc_mad3(8, w, h, 0); + ri->bits_per_channel = 16; + } else + out = (stbi_uc *) stbi__malloc(4 * w*h); + + if (!out) return stbi__errpuc("outofmem", "Out of memory"); + pixelCount = w*h; + + // Initialize the data to zero. + //memset( out, 0, pixelCount * 4 ); + + // Finally, the image data. + if (compression) { + // RLE as used by .PSD and .TIFF + // Loop until you get the number of unpacked bytes you are expecting: + // Read the next source byte into n. + // If n is between 0 and 127 inclusive, copy the next n+1 bytes literally. + // Else if n is between -127 and -1 inclusive, copy the next byte -n+1 times. + // Else if n is 128, noop. + // Endloop + + // The RLE-compressed data is preceded by a 2-byte data count for each row in the data, + // which we're going to just skip. + stbi__skip(s, h * channelCount * 2 ); + + // Read the RLE data by channel. + for (channel = 0; channel < 4; channel++) { + stbi_uc *p; + + p = out+channel; + if (channel >= channelCount) { + // Fill this channel with default data. + for (i = 0; i < pixelCount; i++, p += 4) + *p = (channel == 3 ? 255 : 0); + } else { + // Read the RLE data. + if (!stbi__psd_decode_rle(s, p, pixelCount)) { + STBI_FREE(out); + return stbi__errpuc("corrupt", "bad RLE data"); + } + } + } + + } else { + // We're at the raw image data. It's each channel in order (Red, Green, Blue, Alpha, ...) + // where each channel consists of an 8-bit (or 16-bit) value for each pixel in the image. + + // Read the data by channel. + for (channel = 0; channel < 4; channel++) { + if (channel >= channelCount) { + // Fill this channel with default data. + if (bitdepth == 16 && bpc == 16) { + stbi__uint16 *q = ((stbi__uint16 *) out) + channel; + stbi__uint16 val = channel == 3 ? 65535 : 0; + for (i = 0; i < pixelCount; i++, q += 4) + *q = val; + } else { + stbi_uc *p = out+channel; + stbi_uc val = channel == 3 ? 255 : 0; + for (i = 0; i < pixelCount; i++, p += 4) + *p = val; + } + } else { + if (ri->bits_per_channel == 16) { // output bpc + stbi__uint16 *q = ((stbi__uint16 *) out) + channel; + for (i = 0; i < pixelCount; i++, q += 4) + *q = (stbi__uint16) stbi__get16be(s); + } else { + stbi_uc *p = out+channel; + if (bitdepth == 16) { // input bpc + for (i = 0; i < pixelCount; i++, p += 4) + *p = (stbi_uc) (stbi__get16be(s) >> 8); + } else { + for (i = 0; i < pixelCount; i++, p += 4) + *p = stbi__get8(s); + } + } + } + } + } + + // remove weird white matte from PSD + if (channelCount >= 4) { + if (ri->bits_per_channel == 16) { + for (i=0; i < w*h; ++i) { + stbi__uint16 *pixel = (stbi__uint16 *) out + 4*i; + if (pixel[3] != 0 && pixel[3] != 65535) { + float a = pixel[3] / 65535.0f; + float ra = 1.0f / a; + float inv_a = 65535.0f * (1 - ra); + pixel[0] = (stbi__uint16) (pixel[0]*ra + inv_a); + pixel[1] = (stbi__uint16) (pixel[1]*ra + inv_a); + pixel[2] = (stbi__uint16) (pixel[2]*ra + inv_a); + } + } + } else { + for (i=0; i < w*h; ++i) { + unsigned char *pixel = out + 4*i; + if (pixel[3] != 0 && pixel[3] != 255) { + float a = pixel[3] / 255.0f; + float ra = 1.0f / a; + float inv_a = 255.0f * (1 - ra); + pixel[0] = (unsigned char) (pixel[0]*ra + inv_a); + pixel[1] = (unsigned char) (pixel[1]*ra + inv_a); + pixel[2] = (unsigned char) (pixel[2]*ra + inv_a); + } + } + } + } + + // convert to desired output format + if (req_comp && req_comp != 4) { + if (ri->bits_per_channel == 16) + out = (stbi_uc *) stbi__convert_format16((stbi__uint16 *) out, 4, req_comp, w, h); + else + out = stbi__convert_format(out, 4, req_comp, w, h); + if (out == NULL) return out; // stbi__convert_format frees input on failure + } + + if (comp) *comp = 4; + *y = h; + *x = w; + + return out; +} +#endif + +// ************************************************************************************************* +// Softimage PIC loader +// by Tom Seddon +// +// See http://softimage.wiki.softimage.com/index.php/INFO:_PIC_file_format +// See http://ozviz.wasp.uwa.edu.au/~pbourke/dataformats/softimagepic/ + +#ifndef STBI_NO_PIC +static int stbi__pic_is4(stbi__context *s,const char *str) +{ + int i; + for (i=0; i<4; ++i) + if (stbi__get8(s) != (stbi_uc)str[i]) + return 0; + + return 1; +} + +static int stbi__pic_test_core(stbi__context *s) +{ + int i; + + if (!stbi__pic_is4(s,"\x53\x80\xF6\x34")) + return 0; + + for(i=0;i<84;++i) + stbi__get8(s); + + if (!stbi__pic_is4(s,"PICT")) + return 0; + + return 1; +} + +typedef struct +{ + stbi_uc size,type,channel; +} stbi__pic_packet; + +static stbi_uc *stbi__readval(stbi__context *s, int channel, stbi_uc *dest) +{ + int mask=0x80, i; + + for (i=0; i<4; ++i, mask>>=1) { + if (channel & mask) { + if (stbi__at_eof(s)) return stbi__errpuc("bad file","PIC file too short"); + dest[i]=stbi__get8(s); + } + } + + return dest; +} + +static void stbi__copyval(int channel,stbi_uc *dest,const stbi_uc *src) +{ + int mask=0x80,i; + + for (i=0;i<4; ++i, mask>>=1) + if (channel&mask) + dest[i]=src[i]; +} + +static stbi_uc *stbi__pic_load_core(stbi__context *s,int width,int height,int *comp, stbi_uc *result) +{ + int act_comp=0,num_packets=0,y,chained; + stbi__pic_packet packets[10]; + + // this will (should...) cater for even some bizarre stuff like having data + // for the same channel in multiple packets. + do { + stbi__pic_packet *packet; + + if (num_packets==sizeof(packets)/sizeof(packets[0])) + return stbi__errpuc("bad format","too many packets"); + + packet = &packets[num_packets++]; + + chained = stbi__get8(s); + packet->size = stbi__get8(s); + packet->type = stbi__get8(s); + packet->channel = stbi__get8(s); + + act_comp |= packet->channel; + + if (stbi__at_eof(s)) return stbi__errpuc("bad file","file too short (reading packets)"); + if (packet->size != 8) return stbi__errpuc("bad format","packet isn't 8bpp"); + } while (chained); + + *comp = (act_comp & 0x10 ? 4 : 3); // has alpha channel? + + for(y=0; ytype) { + default: + return stbi__errpuc("bad format","packet has bad compression type"); + + case 0: {//uncompressed + int x; + + for(x=0;xchannel,dest)) + return 0; + break; + } + + case 1://Pure RLE + { + int left=width, i; + + while (left>0) { + stbi_uc count,value[4]; + + count=stbi__get8(s); + if (stbi__at_eof(s)) return stbi__errpuc("bad file","file too short (pure read count)"); + + if (count > left) + count = (stbi_uc) left; + + if (!stbi__readval(s,packet->channel,value)) return 0; + + for(i=0; ichannel,dest,value); + left -= count; + } + } + break; + + case 2: {//Mixed RLE + int left=width; + while (left>0) { + int count = stbi__get8(s), i; + if (stbi__at_eof(s)) return stbi__errpuc("bad file","file too short (mixed read count)"); + + if (count >= 128) { // Repeated + stbi_uc value[4]; + + if (count==128) + count = stbi__get16be(s); + else + count -= 127; + if (count > left) + return stbi__errpuc("bad file","scanline overrun"); + + if (!stbi__readval(s,packet->channel,value)) + return 0; + + for(i=0;ichannel,dest,value); + } else { // Raw + ++count; + if (count>left) return stbi__errpuc("bad file","scanline overrun"); + + for(i=0;ichannel,dest)) + return 0; + } + left-=count; + } + break; + } + } + } + } + + return result; +} + +static void *stbi__pic_load(stbi__context *s,int *px,int *py,int *comp,int req_comp, stbi__result_info *ri) +{ + stbi_uc *result; + int i, x,y, internal_comp; + STBI_NOTUSED(ri); + + if (!comp) comp = &internal_comp; + + for (i=0; i<92; ++i) + stbi__get8(s); + + x = stbi__get16be(s); + y = stbi__get16be(s); + + if (y > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + if (x > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + + if (stbi__at_eof(s)) return stbi__errpuc("bad file","file too short (pic header)"); + if (!stbi__mad3sizes_valid(x, y, 4, 0)) return stbi__errpuc("too large", "PIC image too large to decode"); + + stbi__get32be(s); //skip `ratio' + stbi__get16be(s); //skip `fields' + stbi__get16be(s); //skip `pad' + + // intermediate buffer is RGBA + result = (stbi_uc *) stbi__malloc_mad3(x, y, 4, 0); + memset(result, 0xff, x*y*4); + + if (!stbi__pic_load_core(s,x,y,comp, result)) { + STBI_FREE(result); + result=0; + } + *px = x; + *py = y; + if (req_comp == 0) req_comp = *comp; + result=stbi__convert_format(result,4,req_comp,x,y); + + return result; +} + +static int stbi__pic_test(stbi__context *s) +{ + int r = stbi__pic_test_core(s); + stbi__rewind(s); + return r; +} +#endif + +// ************************************************************************************************* +// GIF loader -- public domain by Jean-Marc Lienher -- simplified/shrunk by stb + +#ifndef STBI_NO_GIF +typedef struct +{ + stbi__int16 prefix; + stbi_uc first; + stbi_uc suffix; +} stbi__gif_lzw; + +typedef struct +{ + int w,h; + stbi_uc *out; // output buffer (always 4 components) + stbi_uc *background; // The current "background" as far as a gif is concerned + stbi_uc *history; + int flags, bgindex, ratio, transparent, eflags; + stbi_uc pal[256][4]; + stbi_uc lpal[256][4]; + stbi__gif_lzw codes[8192]; + stbi_uc *color_table; + int parse, step; + int lflags; + int start_x, start_y; + int max_x, max_y; + int cur_x, cur_y; + int line_size; + int delay; +} stbi__gif; + +static int stbi__gif_test_raw(stbi__context *s) +{ + int sz; + if (stbi__get8(s) != 'G' || stbi__get8(s) != 'I' || stbi__get8(s) != 'F' || stbi__get8(s) != '8') return 0; + sz = stbi__get8(s); + if (sz != '9' && sz != '7') return 0; + if (stbi__get8(s) != 'a') return 0; + return 1; +} + +static int stbi__gif_test(stbi__context *s) +{ + int r = stbi__gif_test_raw(s); + stbi__rewind(s); + return r; +} + +static void stbi__gif_parse_colortable(stbi__context *s, stbi_uc pal[256][4], int num_entries, int transp) +{ + int i; + for (i=0; i < num_entries; ++i) { + pal[i][2] = stbi__get8(s); + pal[i][1] = stbi__get8(s); + pal[i][0] = stbi__get8(s); + pal[i][3] = transp == i ? 0 : 255; + } +} + +static int stbi__gif_header(stbi__context *s, stbi__gif *g, int *comp, int is_info) +{ + stbi_uc version; + if (stbi__get8(s) != 'G' || stbi__get8(s) != 'I' || stbi__get8(s) != 'F' || stbi__get8(s) != '8') + return stbi__err("not GIF", "Corrupt GIF"); + + version = stbi__get8(s); + if (version != '7' && version != '9') return stbi__err("not GIF", "Corrupt GIF"); + if (stbi__get8(s) != 'a') return stbi__err("not GIF", "Corrupt GIF"); + + stbi__g_failure_reason = ""; + g->w = stbi__get16le(s); + g->h = stbi__get16le(s); + g->flags = stbi__get8(s); + g->bgindex = stbi__get8(s); + g->ratio = stbi__get8(s); + g->transparent = -1; + + if (g->w > STBI_MAX_DIMENSIONS) return stbi__err("too large","Very large image (corrupt?)"); + if (g->h > STBI_MAX_DIMENSIONS) return stbi__err("too large","Very large image (corrupt?)"); + + if (comp != 0) *comp = 4; // can't actually tell whether it's 3 or 4 until we parse the comments + + if (is_info) return 1; + + if (g->flags & 0x80) + stbi__gif_parse_colortable(s,g->pal, 2 << (g->flags & 7), -1); + + return 1; +} + +static int stbi__gif_info_raw(stbi__context *s, int *x, int *y, int *comp) +{ + stbi__gif* g = (stbi__gif*) stbi__malloc(sizeof(stbi__gif)); + if (!stbi__gif_header(s, g, comp, 1)) { + STBI_FREE(g); + stbi__rewind( s ); + return 0; + } + if (x) *x = g->w; + if (y) *y = g->h; + STBI_FREE(g); + return 1; +} + +static void stbi__out_gif_code(stbi__gif *g, stbi__uint16 code) +{ + stbi_uc *p, *c; + int idx; + + // recurse to decode the prefixes, since the linked-list is backwards, + // and working backwards through an interleaved image would be nasty + if (g->codes[code].prefix >= 0) + stbi__out_gif_code(g, g->codes[code].prefix); + + if (g->cur_y >= g->max_y) return; + + idx = g->cur_x + g->cur_y; + p = &g->out[idx]; + g->history[idx / 4] = 1; + + c = &g->color_table[g->codes[code].suffix * 4]; + if (c[3] > 128) { // don't render transparent pixels; + p[0] = c[2]; + p[1] = c[1]; + p[2] = c[0]; + p[3] = c[3]; + } + g->cur_x += 4; + + if (g->cur_x >= g->max_x) { + g->cur_x = g->start_x; + g->cur_y += g->step; + + while (g->cur_y >= g->max_y && g->parse > 0) { + g->step = (1 << g->parse) * g->line_size; + g->cur_y = g->start_y + (g->step >> 1); + --g->parse; + } + } +} + +static stbi_uc *stbi__process_gif_raster(stbi__context *s, stbi__gif *g) +{ + stbi_uc lzw_cs; + stbi__int32 len, init_code; + stbi__uint32 first; + stbi__int32 codesize, codemask, avail, oldcode, bits, valid_bits, clear; + stbi__gif_lzw *p; + + lzw_cs = stbi__get8(s); + if (lzw_cs > 12) return NULL; + clear = 1 << lzw_cs; + first = 1; + codesize = lzw_cs + 1; + codemask = (1 << codesize) - 1; + bits = 0; + valid_bits = 0; + for (init_code = 0; init_code < clear; init_code++) { + g->codes[init_code].prefix = -1; + g->codes[init_code].first = (stbi_uc) init_code; + g->codes[init_code].suffix = (stbi_uc) init_code; + } + + // support no starting clear code + avail = clear+2; + oldcode = -1; + + len = 0; + for(;;) { + if (valid_bits < codesize) { + if (len == 0) { + len = stbi__get8(s); // start new block + if (len == 0) + return g->out; + } + --len; + bits |= (stbi__int32) stbi__get8(s) << valid_bits; + valid_bits += 8; + } else { + stbi__int32 code = bits & codemask; + bits >>= codesize; + valid_bits -= codesize; + // @OPTIMIZE: is there some way we can accelerate the non-clear path? + if (code == clear) { // clear code + codesize = lzw_cs + 1; + codemask = (1 << codesize) - 1; + avail = clear + 2; + oldcode = -1; + first = 0; + } else if (code == clear + 1) { // end of stream code + stbi__skip(s, len); + while ((len = stbi__get8(s)) > 0) + stbi__skip(s,len); + return g->out; + } else if (code <= avail) { + if (first) { + return stbi__errpuc("no clear code", "Corrupt GIF"); + } + + if (oldcode >= 0) { + p = &g->codes[avail++]; + if (avail > 8192) { + return stbi__errpuc("too many codes", "Corrupt GIF"); + } + + p->prefix = (stbi__int16) oldcode; + p->first = g->codes[oldcode].first; + p->suffix = (code == avail) ? p->first : g->codes[code].first; + } else if (code == avail) + return stbi__errpuc("illegal code in raster", "Corrupt GIF"); + + stbi__out_gif_code(g, (stbi__uint16) code); + + if ((avail & codemask) == 0 && avail <= 0x0FFF) { + codesize++; + codemask = (1 << codesize) - 1; + } + + oldcode = code; + } else { + return stbi__errpuc("illegal code in raster", "Corrupt GIF"); + } + } + } +} + +// this function is designed to support animated gifs, although stb_image doesn't support it +// two back is the image from two frames ago, used for a very specific disposal format +static stbi_uc *stbi__gif_load_next(stbi__context *s, stbi__gif *g, int *comp, int req_comp, stbi_uc *two_back) +{ + int dispose; + int first_frame; + int pi; + int pcount; + STBI_NOTUSED(req_comp); + + // on first frame, any non-written pixels get the background colour (non-transparent) + first_frame = 0; + if (g->out == 0) { + if (!stbi__gif_header(s, g, comp,0)) return 0; // stbi__g_failure_reason set by stbi__gif_header + if (!stbi__mad3sizes_valid(4, g->w, g->h, 0)) + return stbi__errpuc("too large", "GIF image is too large"); + pcount = g->w * g->h; + g->out = (stbi_uc *) stbi__malloc(4 * pcount); + g->background = (stbi_uc *) stbi__malloc(4 * pcount); + g->history = (stbi_uc *) stbi__malloc(pcount); + if (!g->out || !g->background || !g->history) + return stbi__errpuc("outofmem", "Out of memory"); + + // image is treated as "transparent" at the start - ie, nothing overwrites the current background; + // background colour is only used for pixels that are not rendered first frame, after that "background" + // color refers to the color that was there the previous frame. + memset(g->out, 0x00, 4 * pcount); + memset(g->background, 0x00, 4 * pcount); // state of the background (starts transparent) + memset(g->history, 0x00, pcount); // pixels that were affected previous frame + first_frame = 1; + } else { + // second frame - how do we dispose of the previous one? + dispose = (g->eflags & 0x1C) >> 2; + pcount = g->w * g->h; + + if ((dispose == 3) && (two_back == 0)) { + dispose = 2; // if I don't have an image to revert back to, default to the old background + } + + if (dispose == 3) { // use previous graphic + for (pi = 0; pi < pcount; ++pi) { + if (g->history[pi]) { + memcpy( &g->out[pi * 4], &two_back[pi * 4], 4 ); + } + } + } else if (dispose == 2) { + // restore what was changed last frame to background before that frame; + for (pi = 0; pi < pcount; ++pi) { + if (g->history[pi]) { + memcpy( &g->out[pi * 4], &g->background[pi * 4], 4 ); + } + } + } else { + // This is a non-disposal case eithe way, so just + // leave the pixels as is, and they will become the new background + // 1: do not dispose + // 0: not specified. + } + + // background is what out is after the undoing of the previou frame; + memcpy( g->background, g->out, 4 * g->w * g->h ); + } + + // clear my history; + memset( g->history, 0x00, g->w * g->h ); // pixels that were affected previous frame + + for (;;) { + int tag = stbi__get8(s); + switch (tag) { + case 0x2C: /* Image Descriptor */ + { + stbi__int32 x, y, w, h; + stbi_uc *o; + + x = stbi__get16le(s); + y = stbi__get16le(s); + w = stbi__get16le(s); + h = stbi__get16le(s); + if (((x + w) > (g->w)) || ((y + h) > (g->h))) + return stbi__errpuc("bad Image Descriptor", "Corrupt GIF"); + + g->line_size = g->w * 4; + g->start_x = x * 4; + g->start_y = y * g->line_size; + g->max_x = g->start_x + w * 4; + g->max_y = g->start_y + h * g->line_size; + g->cur_x = g->start_x; + g->cur_y = g->start_y; + + // if the width of the specified rectangle is 0, that means + // we may not see *any* pixels or the image is malformed; + // to make sure this is caught, move the current y down to + // max_y (which is what out_gif_code checks). + if (w == 0) + g->cur_y = g->max_y; + + g->lflags = stbi__get8(s); + + if (g->lflags & 0x40) { + g->step = 8 * g->line_size; // first interlaced spacing + g->parse = 3; + } else { + g->step = g->line_size; + g->parse = 0; + } + + if (g->lflags & 0x80) { + stbi__gif_parse_colortable(s,g->lpal, 2 << (g->lflags & 7), g->eflags & 0x01 ? g->transparent : -1); + g->color_table = (stbi_uc *) g->lpal; + } else if (g->flags & 0x80) { + g->color_table = (stbi_uc *) g->pal; + } else + return stbi__errpuc("missing color table", "Corrupt GIF"); + + o = stbi__process_gif_raster(s, g); + if (!o) return NULL; + + // if this was the first frame, + pcount = g->w * g->h; + if (first_frame && (g->bgindex > 0)) { + // if first frame, any pixel not drawn to gets the background color + for (pi = 0; pi < pcount; ++pi) { + if (g->history[pi] == 0) { + g->pal[g->bgindex][3] = 255; // just in case it was made transparent, undo that; It will be reset next frame if need be; + memcpy( &g->out[pi * 4], &g->pal[g->bgindex], 4 ); + } + } + } + + return o; + } + + case 0x21: // Comment Extension. + { + int len; + int ext = stbi__get8(s); + if (ext == 0xF9) { // Graphic Control Extension. + len = stbi__get8(s); + if (len == 4) { + g->eflags = stbi__get8(s); + g->delay = 10 * stbi__get16le(s); // delay - 1/100th of a second, saving as 1/1000ths. + + // unset old transparent + if (g->transparent >= 0) { + g->pal[g->transparent][3] = 255; + } + if (g->eflags & 0x01) { + g->transparent = stbi__get8(s); + if (g->transparent >= 0) { + g->pal[g->transparent][3] = 0; + } + } else { + // don't need transparent + stbi__skip(s, 1); + g->transparent = -1; + } + } else { + stbi__skip(s, len); + break; + } + } + while ((len = stbi__get8(s)) != 0) { + stbi__skip(s, len); + } + break; + } + + case 0x3B: // gif stream termination code + return (stbi_uc *) s; // using '1' causes warning on some compilers + + default: + return stbi__errpuc("unknown code", "Corrupt GIF"); + } + } +} + +static void *stbi__load_gif_main(stbi__context *s, int **delays, int *x, int *y, int *z, int *comp, int req_comp) +{ + if (stbi__gif_test(s)) { + int layers = 0; + stbi_uc *u = 0; + stbi_uc *out = 0; + stbi_uc *two_back = 0; + stbi__gif g; + int stride; + int out_size = 0; + int delays_size = 0; + memset(&g, 0, sizeof(g)); + if (delays) { + *delays = 0; + } + + do { + u = stbi__gif_load_next(s, &g, comp, req_comp, two_back); + if (u == (stbi_uc *) s) u = 0; // end of animated gif marker + + if (u) { + *x = g.w; + *y = g.h; + ++layers; + stride = g.w * g.h * 4; + + if (out) { + void *tmp = (stbi_uc*) STBI_REALLOC_SIZED( out, out_size, layers * stride ); + if (NULL == tmp) { + STBI_FREE(g.out); + STBI_FREE(g.history); + STBI_FREE(g.background); + return stbi__errpuc("outofmem", "Out of memory"); + } + else { + out = (stbi_uc*) tmp; + out_size = layers * stride; + } + + if (delays) { + *delays = (int*) STBI_REALLOC_SIZED( *delays, delays_size, sizeof(int) * layers ); + delays_size = layers * sizeof(int); + } + } else { + out = (stbi_uc*)stbi__malloc( layers * stride ); + out_size = layers * stride; + if (delays) { + *delays = (int*) stbi__malloc( layers * sizeof(int) ); + delays_size = layers * sizeof(int); + } + } + memcpy( out + ((layers - 1) * stride), u, stride ); + if (layers >= 2) { + two_back = out - 2 * stride; + } + + if (delays) { + (*delays)[layers - 1U] = g.delay; + } + } + } while (u != 0); + + // free temp buffer; + STBI_FREE(g.out); + STBI_FREE(g.history); + STBI_FREE(g.background); + + // do the final conversion after loading everything; + if (req_comp && req_comp != 4) + out = stbi__convert_format(out, 4, req_comp, layers * g.w, g.h); + + *z = layers; + return out; + } else { + return stbi__errpuc("not GIF", "Image was not as a gif type."); + } +} + +static void *stbi__gif_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +{ + stbi_uc *u = 0; + stbi__gif g; + memset(&g, 0, sizeof(g)); + STBI_NOTUSED(ri); + + u = stbi__gif_load_next(s, &g, comp, req_comp, 0); + if (u == (stbi_uc *) s) u = 0; // end of animated gif marker + if (u) { + *x = g.w; + *y = g.h; + + // moved conversion to after successful load so that the same + // can be done for multiple frames. + if (req_comp && req_comp != 4) + u = stbi__convert_format(u, 4, req_comp, g.w, g.h); + } else if (g.out) { + // if there was an error and we allocated an image buffer, free it! + STBI_FREE(g.out); + } + + // free buffers needed for multiple frame loading; + STBI_FREE(g.history); + STBI_FREE(g.background); + + return u; +} + +static int stbi__gif_info(stbi__context *s, int *x, int *y, int *comp) +{ + return stbi__gif_info_raw(s,x,y,comp); +} +#endif + +// ************************************************************************************************* +// Radiance RGBE HDR loader +// originally by Nicolas Schulz +#ifndef STBI_NO_HDR +static int stbi__hdr_test_core(stbi__context *s, const char *signature) +{ + int i; + for (i=0; signature[i]; ++i) + if (stbi__get8(s) != signature[i]) + return 0; + stbi__rewind(s); + return 1; +} + +static int stbi__hdr_test(stbi__context* s) +{ + int r = stbi__hdr_test_core(s, "#?RADIANCE\n"); + stbi__rewind(s); + if(!r) { + r = stbi__hdr_test_core(s, "#?RGBE\n"); + stbi__rewind(s); + } + return r; +} + +#define STBI__HDR_BUFLEN 1024 +static char *stbi__hdr_gettoken(stbi__context *z, char *buffer) +{ + int len=0; + char c = '\0'; + + c = (char) stbi__get8(z); + + while (!stbi__at_eof(z) && c != '\n') { + buffer[len++] = c; + if (len == STBI__HDR_BUFLEN-1) { + // flush to end of line + while (!stbi__at_eof(z) && stbi__get8(z) != '\n') + ; + break; + } + c = (char) stbi__get8(z); + } + + buffer[len] = 0; + return buffer; +} + +static void stbi__hdr_convert(float *output, stbi_uc *input, int req_comp) +{ + if ( input[3] != 0 ) { + float f1; + // Exponent + f1 = (float) ldexp(1.0f, input[3] - (int)(128 + 8)); + if (req_comp <= 2) + output[0] = (input[0] + input[1] + input[2]) * f1 / 3; + else { + output[0] = input[0] * f1; + output[1] = input[1] * f1; + output[2] = input[2] * f1; + } + if (req_comp == 2) output[1] = 1; + if (req_comp == 4) output[3] = 1; + } else { + switch (req_comp) { + case 4: output[3] = 1; /* fallthrough */ + case 3: output[0] = output[1] = output[2] = 0; + break; + case 2: output[1] = 1; /* fallthrough */ + case 1: output[0] = 0; + break; + } + } +} + +static float *stbi__hdr_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +{ + char buffer[STBI__HDR_BUFLEN]; + char *token; + int valid = 0; + int width, height; + stbi_uc *scanline; + float *hdr_data; + int len; + unsigned char count, value; + int i, j, k, c1,c2, z; + const char *headerToken; + STBI_NOTUSED(ri); + + // Check identifier + headerToken = stbi__hdr_gettoken(s,buffer); + if (strcmp(headerToken, "#?RADIANCE") != 0 && strcmp(headerToken, "#?RGBE") != 0) + return stbi__errpf("not HDR", "Corrupt HDR image"); + + // Parse header + for(;;) { + token = stbi__hdr_gettoken(s,buffer); + if (token[0] == 0) break; + if (strcmp(token, "FORMAT=32-bit_rle_rgbe") == 0) valid = 1; + } + + if (!valid) return stbi__errpf("unsupported format", "Unsupported HDR format"); + + // Parse width and height + // can't use sscanf() if we're not using stdio! + token = stbi__hdr_gettoken(s,buffer); + if (strncmp(token, "-Y ", 3)) return stbi__errpf("unsupported data layout", "Unsupported HDR format"); + token += 3; + height = (int) strtol(token, &token, 10); + while (*token == ' ') ++token; + if (strncmp(token, "+X ", 3)) return stbi__errpf("unsupported data layout", "Unsupported HDR format"); + token += 3; + width = (int) strtol(token, NULL, 10); + + if (height > STBI_MAX_DIMENSIONS) return stbi__errpf("too large","Very large image (corrupt?)"); + if (width > STBI_MAX_DIMENSIONS) return stbi__errpf("too large","Very large image (corrupt?)"); + + *x = width; + *y = height; + + if (comp) *comp = 3; + if (req_comp == 0) req_comp = 3; + + if (!stbi__mad4sizes_valid(width, height, req_comp, sizeof(float), 0)) + return stbi__errpf("too large", "HDR image is too large"); + + // Read data + hdr_data = (float *) stbi__malloc_mad4(width, height, req_comp, sizeof(float), 0); + if (!hdr_data) + return stbi__errpf("outofmem", "Out of memory"); + + // Load image data + // image data is stored as some number of sca + if ( width < 8 || width >= 32768) { + // Read flat data + for (j=0; j < height; ++j) { + for (i=0; i < width; ++i) { + stbi_uc rgbe[4]; + main_decode_loop: + stbi__getn(s, rgbe, 4); + stbi__hdr_convert(hdr_data + j * width * req_comp + i * req_comp, rgbe, req_comp); + } + } + } else { + // Read RLE-encoded data + scanline = NULL; + + for (j = 0; j < height; ++j) { + c1 = stbi__get8(s); + c2 = stbi__get8(s); + len = stbi__get8(s); + if (c1 != 2 || c2 != 2 || (len & 0x80)) { + // not run-length encoded, so we have to actually use THIS data as a decoded + // pixel (note this can't be a valid pixel--one of RGB must be >= 128) + stbi_uc rgbe[4]; + rgbe[0] = (stbi_uc) c1; + rgbe[1] = (stbi_uc) c2; + rgbe[2] = (stbi_uc) len; + rgbe[3] = (stbi_uc) stbi__get8(s); + stbi__hdr_convert(hdr_data, rgbe, req_comp); + i = 1; + j = 0; + STBI_FREE(scanline); + goto main_decode_loop; // yes, this makes no sense + } + len <<= 8; + len |= stbi__get8(s); + if (len != width) { STBI_FREE(hdr_data); STBI_FREE(scanline); return stbi__errpf("invalid decoded scanline length", "corrupt HDR"); } + if (scanline == NULL) { + scanline = (stbi_uc *) stbi__malloc_mad2(width, 4, 0); + if (!scanline) { + STBI_FREE(hdr_data); + return stbi__errpf("outofmem", "Out of memory"); + } + } + + for (k = 0; k < 4; ++k) { + int nleft; + i = 0; + while ((nleft = width - i) > 0) { + count = stbi__get8(s); + if (count > 128) { + // Run + value = stbi__get8(s); + count -= 128; + if (count > nleft) { STBI_FREE(hdr_data); STBI_FREE(scanline); return stbi__errpf("corrupt", "bad RLE data in HDR"); } + for (z = 0; z < count; ++z) + scanline[i++ * 4 + k] = value; + } else { + // Dump + if (count > nleft) { STBI_FREE(hdr_data); STBI_FREE(scanline); return stbi__errpf("corrupt", "bad RLE data in HDR"); } + for (z = 0; z < count; ++z) + scanline[i++ * 4 + k] = stbi__get8(s); + } + } + } + for (i=0; i < width; ++i) + stbi__hdr_convert(hdr_data+(j*width + i)*req_comp, scanline + i*4, req_comp); + } + if (scanline) + STBI_FREE(scanline); + } + + return hdr_data; +} + +static int stbi__hdr_info(stbi__context *s, int *x, int *y, int *comp) +{ + char buffer[STBI__HDR_BUFLEN]; + char *token; + int valid = 0; + int dummy; + + if (!x) x = &dummy; + if (!y) y = &dummy; + if (!comp) comp = &dummy; + + if (stbi__hdr_test(s) == 0) { + stbi__rewind( s ); + return 0; + } + + for(;;) { + token = stbi__hdr_gettoken(s,buffer); + if (token[0] == 0) break; + if (strcmp(token, "FORMAT=32-bit_rle_rgbe") == 0) valid = 1; + } + + if (!valid) { + stbi__rewind( s ); + return 0; + } + token = stbi__hdr_gettoken(s,buffer); + if (strncmp(token, "-Y ", 3)) { + stbi__rewind( s ); + return 0; + } + token += 3; + *y = (int) strtol(token, &token, 10); + while (*token == ' ') ++token; + if (strncmp(token, "+X ", 3)) { + stbi__rewind( s ); + return 0; + } + token += 3; + *x = (int) strtol(token, NULL, 10); + *comp = 3; + return 1; +} +#endif // STBI_NO_HDR + +#ifndef STBI_NO_BMP +static int stbi__bmp_info(stbi__context *s, int *x, int *y, int *comp) +{ + void *p; + stbi__bmp_data info; + + info.all_a = 255; + p = stbi__bmp_parse_header(s, &info); + stbi__rewind( s ); + if (p == NULL) + return 0; + if (x) *x = s->img_x; + if (y) *y = s->img_y; + if (comp) { + if (info.bpp == 24 && info.ma == 0xff000000) + *comp = 3; + else + *comp = info.ma ? 4 : 3; + } + return 1; +} +#endif + +#ifndef STBI_NO_PSD +static int stbi__psd_info(stbi__context *s, int *x, int *y, int *comp) +{ + int channelCount, dummy, depth; + if (!x) x = &dummy; + if (!y) y = &dummy; + if (!comp) comp = &dummy; + if (stbi__get32be(s) != 0x38425053) { + stbi__rewind( s ); + return 0; + } + if (stbi__get16be(s) != 1) { + stbi__rewind( s ); + return 0; + } + stbi__skip(s, 6); + channelCount = stbi__get16be(s); + if (channelCount < 0 || channelCount > 16) { + stbi__rewind( s ); + return 0; + } + *y = stbi__get32be(s); + *x = stbi__get32be(s); + depth = stbi__get16be(s); + if (depth != 8 && depth != 16) { + stbi__rewind( s ); + return 0; + } + if (stbi__get16be(s) != 3) { + stbi__rewind( s ); + return 0; + } + *comp = 4; + return 1; +} + +static int stbi__psd_is16(stbi__context *s) +{ + int channelCount, depth; + if (stbi__get32be(s) != 0x38425053) { + stbi__rewind( s ); + return 0; + } + if (stbi__get16be(s) != 1) { + stbi__rewind( s ); + return 0; + } + stbi__skip(s, 6); + channelCount = stbi__get16be(s); + if (channelCount < 0 || channelCount > 16) { + stbi__rewind( s ); + return 0; + } + (void) stbi__get32be(s); + (void) stbi__get32be(s); + depth = stbi__get16be(s); + if (depth != 16) { + stbi__rewind( s ); + return 0; + } + return 1; +} +#endif + +#ifndef STBI_NO_PIC +static int stbi__pic_info(stbi__context *s, int *x, int *y, int *comp) +{ + int act_comp=0,num_packets=0,chained,dummy; + stbi__pic_packet packets[10]; + + if (!x) x = &dummy; + if (!y) y = &dummy; + if (!comp) comp = &dummy; + + if (!stbi__pic_is4(s,"\x53\x80\xF6\x34")) { + stbi__rewind(s); + return 0; + } + + stbi__skip(s, 88); + + *x = stbi__get16be(s); + *y = stbi__get16be(s); + if (stbi__at_eof(s)) { + stbi__rewind( s); + return 0; + } + if ( (*x) != 0 && (1 << 28) / (*x) < (*y)) { + stbi__rewind( s ); + return 0; + } + + stbi__skip(s, 8); + + do { + stbi__pic_packet *packet; + + if (num_packets==sizeof(packets)/sizeof(packets[0])) + return 0; + + packet = &packets[num_packets++]; + chained = stbi__get8(s); + packet->size = stbi__get8(s); + packet->type = stbi__get8(s); + packet->channel = stbi__get8(s); + act_comp |= packet->channel; + + if (stbi__at_eof(s)) { + stbi__rewind( s ); + return 0; + } + if (packet->size != 8) { + stbi__rewind( s ); + return 0; + } + } while (chained); + + *comp = (act_comp & 0x10 ? 4 : 3); + + return 1; +} +#endif + +// ************************************************************************************************* +// Portable Gray Map and Portable Pixel Map loader +// by Ken Miller +// +// PGM: http://netpbm.sourceforge.net/doc/pgm.html +// PPM: http://netpbm.sourceforge.net/doc/ppm.html +// +// Known limitations: +// Does not support comments in the header section +// Does not support ASCII image data (formats P2 and P3) +// Does not support 16-bit-per-channel + +#ifndef STBI_NO_PNM + +static int stbi__pnm_test(stbi__context *s) +{ + char p, t; + p = (char) stbi__get8(s); + t = (char) stbi__get8(s); + if (p != 'P' || (t != '5' && t != '6')) { + stbi__rewind( s ); + return 0; + } + return 1; +} + +static void *stbi__pnm_load(stbi__context *s, int *x, int *y, int *comp, int req_comp, stbi__result_info *ri) +{ + stbi_uc *out; + STBI_NOTUSED(ri); + + if (!stbi__pnm_info(s, (int *)&s->img_x, (int *)&s->img_y, (int *)&s->img_n)) + return 0; + + if (s->img_y > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + if (s->img_x > STBI_MAX_DIMENSIONS) return stbi__errpuc("too large","Very large image (corrupt?)"); + + *x = s->img_x; + *y = s->img_y; + if (comp) *comp = s->img_n; + + if (!stbi__mad3sizes_valid(s->img_n, s->img_x, s->img_y, 0)) + return stbi__errpuc("too large", "PNM too large"); + + out = (stbi_uc *) stbi__malloc_mad3(s->img_n, s->img_x, s->img_y, 0); + if (!out) return stbi__errpuc("outofmem", "Out of memory"); + stbi__getn(s, out, s->img_n * s->img_x * s->img_y); + + if (req_comp && req_comp != s->img_n) { + out = stbi__convert_format(out, s->img_n, req_comp, s->img_x, s->img_y); + if (out == NULL) return out; // stbi__convert_format frees input on failure + } + return out; +} + +static int stbi__pnm_isspace(char c) +{ + return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r'; +} + +static void stbi__pnm_skip_whitespace(stbi__context *s, char *c) +{ + for (;;) { + while (!stbi__at_eof(s) && stbi__pnm_isspace(*c)) + *c = (char) stbi__get8(s); + + if (stbi__at_eof(s) || *c != '#') + break; + + while (!stbi__at_eof(s) && *c != '\n' && *c != '\r' ) + *c = (char) stbi__get8(s); + } +} + +static int stbi__pnm_isdigit(char c) +{ + return c >= '0' && c <= '9'; +} + +static int stbi__pnm_getinteger(stbi__context *s, char *c) +{ + int value = 0; + + while (!stbi__at_eof(s) && stbi__pnm_isdigit(*c)) { + value = value*10 + (*c - '0'); + *c = (char) stbi__get8(s); + } + + return value; +} + +static int stbi__pnm_info(stbi__context *s, int *x, int *y, int *comp) +{ + int maxv, dummy; + char c, p, t; + + if (!x) x = &dummy; + if (!y) y = &dummy; + if (!comp) comp = &dummy; + + stbi__rewind(s); + + // Get identifier + p = (char) stbi__get8(s); + t = (char) stbi__get8(s); + if (p != 'P' || (t != '5' && t != '6')) { + stbi__rewind(s); + return 0; + } + + *comp = (t == '6') ? 3 : 1; // '5' is 1-component .pgm; '6' is 3-component .ppm + + c = (char) stbi__get8(s); + stbi__pnm_skip_whitespace(s, &c); + + *x = stbi__pnm_getinteger(s, &c); // read width + stbi__pnm_skip_whitespace(s, &c); + + *y = stbi__pnm_getinteger(s, &c); // read height + stbi__pnm_skip_whitespace(s, &c); + + maxv = stbi__pnm_getinteger(s, &c); // read max value + + if (maxv > 255) + return stbi__err("max value > 255", "PPM image not 8-bit"); + else + return 1; +} +#endif + +static int stbi__info_main(stbi__context *s, int *x, int *y, int *comp) +{ + #ifndef STBI_NO_JPEG + if (stbi__jpeg_info(s, x, y, comp)) return 1; + #endif + + #ifndef STBI_NO_PNG + if (stbi__png_info(s, x, y, comp)) return 1; + #endif + + #ifndef STBI_NO_GIF + if (stbi__gif_info(s, x, y, comp)) return 1; + #endif + + #ifndef STBI_NO_BMP + if (stbi__bmp_info(s, x, y, comp)) return 1; + #endif + + #ifndef STBI_NO_PSD + if (stbi__psd_info(s, x, y, comp)) return 1; + #endif + + #ifndef STBI_NO_PIC + if (stbi__pic_info(s, x, y, comp)) return 1; + #endif + + #ifndef STBI_NO_PNM + if (stbi__pnm_info(s, x, y, comp)) return 1; + #endif + + #ifndef STBI_NO_HDR + if (stbi__hdr_info(s, x, y, comp)) return 1; + #endif + + // test tga last because it's a crappy test! + #ifndef STBI_NO_TGA + if (stbi__tga_info(s, x, y, comp)) + return 1; + #endif + return stbi__err("unknown image type", "Image not of any known type, or corrupt"); +} + +static int stbi__is_16_main(stbi__context *s) +{ + #ifndef STBI_NO_PNG + if (stbi__png_is16(s)) return 1; + #endif + + #ifndef STBI_NO_PSD + if (stbi__psd_is16(s)) return 1; + #endif + + return 0; +} + +#ifndef STBI_NO_STDIO +STBIDEF int stbi_info(char const *filename, int *x, int *y, int *comp) +{ + FILE *f = stbi__fopen(filename, "rb"); + int result; + if (!f) return stbi__err("can't fopen", "Unable to open file"); + result = stbi_info_from_file(f, x, y, comp); + fclose(f); + return result; +} + +STBIDEF int stbi_info_from_file(FILE *f, int *x, int *y, int *comp) +{ + int r; + stbi__context s; + long pos = ftell(f); + stbi__start_file(&s, f); + r = stbi__info_main(&s,x,y,comp); + fseek(f,pos,SEEK_SET); + return r; +} + +STBIDEF int stbi_is_16_bit(char const *filename) +{ + FILE *f = stbi__fopen(filename, "rb"); + int result; + if (!f) return stbi__err("can't fopen", "Unable to open file"); + result = stbi_is_16_bit_from_file(f); + fclose(f); + return result; +} + +STBIDEF int stbi_is_16_bit_from_file(FILE *f) +{ + int r; + stbi__context s; + long pos = ftell(f); + stbi__start_file(&s, f); + r = stbi__is_16_main(&s); + fseek(f,pos,SEEK_SET); + return r; +} +#endif // !STBI_NO_STDIO + +STBIDEF int stbi_info_from_memory(stbi_uc const *buffer, int len, int *x, int *y, int *comp) +{ + stbi__context s; + stbi__start_mem(&s,buffer,len); + return stbi__info_main(&s,x,y,comp); +} + +STBIDEF int stbi_info_from_callbacks(stbi_io_callbacks const *c, void *user, int *x, int *y, int *comp) +{ + stbi__context s; + stbi__start_callbacks(&s, (stbi_io_callbacks *) c, user); + return stbi__info_main(&s,x,y,comp); +} + +STBIDEF int stbi_is_16_bit_from_memory(stbi_uc const *buffer, int len) +{ + stbi__context s; + stbi__start_mem(&s,buffer,len); + return stbi__is_16_main(&s); +} + +STBIDEF int stbi_is_16_bit_from_callbacks(stbi_io_callbacks const *c, void *user) +{ + stbi__context s; + stbi__start_callbacks(&s, (stbi_io_callbacks *) c, user); + return stbi__is_16_main(&s); +} + +#endif // STB_IMAGE_IMPLEMENTATION + +/* + revision history: + 2.20 (2019-02-07) support utf8 filenames in Windows; fix warnings and platform ifdefs + 2.19 (2018-02-11) fix warning + 2.18 (2018-01-30) fix warnings + 2.17 (2018-01-29) change sbti__shiftsigned to avoid clang -O2 bug + 1-bit BMP + *_is_16_bit api + avoid warnings + 2.16 (2017-07-23) all functions have 16-bit variants; + STBI_NO_STDIO works again; + compilation fixes; + fix rounding in unpremultiply; + optimize vertical flip; + disable raw_len validation; + documentation fixes + 2.15 (2017-03-18) fix png-1,2,4 bug; now all Imagenet JPGs decode; + warning fixes; disable run-time SSE detection on gcc; + uniform handling of optional "return" values; + thread-safe initialization of zlib tables + 2.14 (2017-03-03) remove deprecated STBI_JPEG_OLD; fixes for Imagenet JPGs + 2.13 (2016-11-29) add 16-bit API, only supported for PNG right now + 2.12 (2016-04-02) fix typo in 2.11 PSD fix that caused crashes + 2.11 (2016-04-02) allocate large structures on the stack + remove white matting for transparent PSD + fix reported channel count for PNG & BMP + re-enable SSE2 in non-gcc 64-bit + support RGB-formatted JPEG + read 16-bit PNGs (only as 8-bit) + 2.10 (2016-01-22) avoid warning introduced in 2.09 by STBI_REALLOC_SIZED + 2.09 (2016-01-16) allow comments in PNM files + 16-bit-per-pixel TGA (not bit-per-component) + info() for TGA could break due to .hdr handling + info() for BMP to shares code instead of sloppy parse + can use STBI_REALLOC_SIZED if allocator doesn't support realloc + code cleanup + 2.08 (2015-09-13) fix to 2.07 cleanup, reading RGB PSD as RGBA + 2.07 (2015-09-13) fix compiler warnings + partial animated GIF support + limited 16-bpc PSD support + #ifdef unused functions + bug with < 92 byte PIC,PNM,HDR,TGA + 2.06 (2015-04-19) fix bug where PSD returns wrong '*comp' value + 2.05 (2015-04-19) fix bug in progressive JPEG handling, fix warning + 2.04 (2015-04-15) try to re-enable SIMD on MinGW 64-bit + 2.03 (2015-04-12) extra corruption checking (mmozeiko) + stbi_set_flip_vertically_on_load (nguillemot) + fix NEON support; fix mingw support + 2.02 (2015-01-19) fix incorrect assert, fix warning + 2.01 (2015-01-17) fix various warnings; suppress SIMD on gcc 32-bit without -msse2 + 2.00b (2014-12-25) fix STBI_MALLOC in progressive JPEG + 2.00 (2014-12-25) optimize JPG, including x86 SSE2 & NEON SIMD (ryg) + progressive JPEG (stb) + PGM/PPM support (Ken Miller) + STBI_MALLOC,STBI_REALLOC,STBI_FREE + GIF bugfix -- seemingly never worked + STBI_NO_*, STBI_ONLY_* + 1.48 (2014-12-14) fix incorrectly-named assert() + 1.47 (2014-12-14) 1/2/4-bit PNG support, both direct and paletted (Omar Cornut & stb) + optimize PNG (ryg) + fix bug in interlaced PNG with user-specified channel count (stb) + 1.46 (2014-08-26) + fix broken tRNS chunk (colorkey-style transparency) in non-paletted PNG + 1.45 (2014-08-16) + fix MSVC-ARM internal compiler error by wrapping malloc + 1.44 (2014-08-07) + various warning fixes from Ronny Chevalier + 1.43 (2014-07-15) + fix MSVC-only compiler problem in code changed in 1.42 + 1.42 (2014-07-09) + don't define _CRT_SECURE_NO_WARNINGS (affects user code) + fixes to stbi__cleanup_jpeg path + added STBI_ASSERT to avoid requiring assert.h + 1.41 (2014-06-25) + fix search&replace from 1.36 that messed up comments/error messages + 1.40 (2014-06-22) + fix gcc struct-initialization warning + 1.39 (2014-06-15) + fix to TGA optimization when req_comp != number of components in TGA; + fix to GIF loading because BMP wasn't rewinding (whoops, no GIFs in my test suite) + add support for BMP version 5 (more ignored fields) + 1.38 (2014-06-06) + suppress MSVC warnings on integer casts truncating values + fix accidental rename of 'skip' field of I/O + 1.37 (2014-06-04) + remove duplicate typedef + 1.36 (2014-06-03) + convert to header file single-file library + if de-iphone isn't set, load iphone images color-swapped instead of returning NULL + 1.35 (2014-05-27) + various warnings + fix broken STBI_SIMD path + fix bug where stbi_load_from_file no longer left file pointer in correct place + fix broken non-easy path for 32-bit BMP (possibly never used) + TGA optimization by Arseny Kapoulkine + 1.34 (unknown) + use STBI_NOTUSED in stbi__resample_row_generic(), fix one more leak in tga failure case + 1.33 (2011-07-14) + make stbi_is_hdr work in STBI_NO_HDR (as specified), minor compiler-friendly improvements + 1.32 (2011-07-13) + support for "info" function for all supported filetypes (SpartanJ) + 1.31 (2011-06-20) + a few more leak fixes, bug in PNG handling (SpartanJ) + 1.30 (2011-06-11) + added ability to load files via callbacks to accomidate custom input streams (Ben Wenger) + removed deprecated format-specific test/load functions + removed support for installable file formats (stbi_loader) -- would have been broken for IO callbacks anyway + error cases in bmp and tga give messages and don't leak (Raymond Barbiero, grisha) + fix inefficiency in decoding 32-bit BMP (David Woo) + 1.29 (2010-08-16) + various warning fixes from Aurelien Pocheville + 1.28 (2010-08-01) + fix bug in GIF palette transparency (SpartanJ) + 1.27 (2010-08-01) + cast-to-stbi_uc to fix warnings + 1.26 (2010-07-24) + fix bug in file buffering for PNG reported by SpartanJ + 1.25 (2010-07-17) + refix trans_data warning (Won Chun) + 1.24 (2010-07-12) + perf improvements reading from files on platforms with lock-heavy fgetc() + minor perf improvements for jpeg + deprecated type-specific functions so we'll get feedback if they're needed + attempt to fix trans_data warning (Won Chun) + 1.23 fixed bug in iPhone support + 1.22 (2010-07-10) + removed image *writing* support + stbi_info support from Jetro Lauha + GIF support from Jean-Marc Lienher + iPhone PNG-extensions from James Brown + warning-fixes from Nicolas Schulz and Janez Zemva (i.stbi__err. Janez (U+017D)emva) + 1.21 fix use of 'stbi_uc' in header (reported by jon blow) + 1.20 added support for Softimage PIC, by Tom Seddon + 1.19 bug in interlaced PNG corruption check (found by ryg) + 1.18 (2008-08-02) + fix a threading bug (local mutable static) + 1.17 support interlaced PNG + 1.16 major bugfix - stbi__convert_format converted one too many pixels + 1.15 initialize some fields for thread safety + 1.14 fix threadsafe conversion bug + header-file-only version (#define STBI_HEADER_FILE_ONLY before including) + 1.13 threadsafe + 1.12 const qualifiers in the API + 1.11 Support installable IDCT, colorspace conversion routines + 1.10 Fixes for 64-bit (don't use "unsigned long") + optimized upsampling by Fabian "ryg" Giesen + 1.09 Fix format-conversion for PSD code (bad global variables!) + 1.08 Thatcher Ulrich's PSD code integrated by Nicolas Schulz + 1.07 attempt to fix C++ warning/errors again + 1.06 attempt to fix C++ warning/errors again + 1.05 fix TGA loading to return correct *comp and use good luminance calc + 1.04 default float alpha is 1, not 255; use 'void *' for stbi_image_free + 1.03 bugfixes to STBI_NO_STDIO, STBI_NO_HDR + 1.02 support for (subset of) HDR files, float interface for preferred access to them + 1.01 fix bug: possible bug in handling right-side up bmps... not sure + fix bug: the stbi__bmp_load() and stbi__tga_load() functions didn't work at all + 1.00 interface to zlib that skips zlib header + 0.99 correct handling of alpha in palette + 0.98 TGA loader by lonesock; dynamically add loaders (untested) + 0.97 jpeg errors on too large a file; also catch another malloc failure + 0.96 fix detection of invalid v value - particleman@mollyrocket forum + 0.95 during header scan, seek to markers in case of padding + 0.94 STBI_NO_STDIO to disable stdio usage; rename all #defines the same + 0.93 handle jpegtran output; verbose errors + 0.92 read 4,8,16,24,32-bit BMP files of several formats + 0.91 output 24-bit Windows 3.0 BMP files + 0.90 fix a few more warnings; bump version number to approach 1.0 + 0.61 bugfixes due to Marc LeBlanc, Christopher Lloyd + 0.60 fix compiling as c++ + 0.59 fix warnings: merge Dave Moore's -Wall fixes + 0.58 fix bug: zlib uncompressed mode len/nlen was wrong endian + 0.57 fix bug: jpg last huffman symbol before marker was >9 bits but less than 16 available + 0.56 fix bug: zlib uncompressed mode len vs. nlen + 0.55 fix bug: restart_interval not initialized to 0 + 0.54 allow NULL for 'int *comp' + 0.53 fix bug in png 3->4; speedup png decoding + 0.52 png handles req_comp=3,4 directly; minor cleanup; jpeg comments + 0.51 obey req_comp requests, 1-component jpegs return as 1-component, + on 'test' only check type, not whether we support this variant + 0.50 (2006-11-19) + first released version +*/ + + +/* +------------------------------------------------------------------------------ +This software is available under 2 licenses -- choose whichever you prefer. +------------------------------------------------------------------------------ +ALTERNATIVE A - MIT License +Copyright (c) 2017 Sean Barrett +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +------------------------------------------------------------------------------ +ALTERNATIVE B - Public Domain (www.unlicense.org) +This is free and unencumbered software released into the public domain. +Anyone is free to copy, modify, publish, use, compile, sell, or distribute this +software, either in source code form or as a compiled binary, for any purpose, +commercial or non-commercial, and by any means. +In jurisdictions that recognize copyright laws, the author or authors of this +software dedicate any and all copyright interest in the software to the public +domain. We make this dedication for the benefit of the public at large and to +the detriment of our heirs and successors. We intend this dedication to be an +overt act of relinquishment in perpetuity of all present and future rights to +this software under copyright law. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------------ +*/ diff --git a/docs/face-scan-source/lib/README.md b/docs/face-scan-source/lib/README.md new file mode 100644 index 0000000..bcbac85 --- /dev/null +++ b/docs/face-scan-source/lib/README.md @@ -0,0 +1,17 @@ +# Runtime RKNN + +Copiare in questa directory la libreria runtime: + +```text +lib/librknnrt.so +``` + +La libreria deve essere compatibile con: + +- il dispositivo Rockchip utilizzato; +- la versione del driver RKNN; +- i modelli RKNN. + +Il runtime è escluso dal repository tramite `.gitignore`. + +Prima della distribuzione verificare le condizioni di licenza. diff --git a/docs/face-scan-source/models/README.md b/docs/face-scan-source/models/README.md new file mode 100644 index 0000000..2c291b5 --- /dev/null +++ b/docs/face-scan-source/models/README.md @@ -0,0 +1,25 @@ +# Modelli RKNN + +Copiare in questa directory i modelli richiesti: + +```text +models/SCRFD_500M_KPS_640.rknn +models/face_recognition_sface_2021dec.rknn +``` + +## SCRFD + +Il modello SCRFD rileva i volti e produce: + +- confidence score; +- bounding box; +- cinque landmark. + +## SFace + +Il modello SFace riceve un volto allineato a 112 x 112 pixel e produce un +embedding di 128 elementi. + +I modelli sono esclusi dal repository tramite `.gitignore`. + +Prima della distribuzione verificare le rispettive licenze. diff --git a/docs/face-scan-source/models/SCRFD_500M_KPS_640.rknn b/docs/face-scan-source/models/SCRFD_500M_KPS_640.rknn new file mode 100644 index 0000000..ae8757c Binary files /dev/null and b/docs/face-scan-source/models/SCRFD_500M_KPS_640.rknn differ diff --git a/docs/face-scan-source/models/face_recognition_sface_2021dec.rknn b/docs/face-scan-source/models/face_recognition_sface_2021dec.rknn new file mode 100644 index 0000000..ebc0fe0 Binary files /dev/null and b/docs/face-scan-source/models/face_recognition_sface_2021dec.rknn differ diff --git a/docs/face-scan-source/src/face_engine.cc b/docs/face-scan-source/src/face_engine.cc new file mode 100644 index 0000000..46e43c0 --- /dev/null +++ b/docs/face-scan-source/src/face_engine.cc @@ -0,0 +1,856 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define STB_IMAGE_IMPLEMENTATION +#include "stb_image.h" + +#include "rknn_api.h" +#include "face_engine.h" + +static constexpr int SCRFD_SIZE = 640; +static constexpr int SFACE_SIZE = 112; +static constexpr int EMBED_DIM = 128; + +static uint16_t float_to_fp16(float value) +{ + uint32_t bits; + std::memcpy(&bits, &value, sizeof(bits)); + + uint32_t sign = (bits >> 31) & 1; + int exp = ((bits >> 23) & 0xff) - 127; + uint32_t mant = bits & 0x7fffff; + + if (exp == 128) { + return (sign << 15) | 0x7c00; + } + + if (exp > 15) { + return (sign << 15) | 0x7c00; + } + + if (exp < -14) { + if (exp < -24) + return sign << 15; + + mant |= 0x800000; + int shift = -exp - 14; + uint16_t m = mant >> (shift + 13); + + return (sign << 15) | m; + } + + uint16_t h_exp = (uint16_t)(exp + 15); + uint16_t h_mant = (uint16_t)(mant >> 13); + + return (sign << 15) | (h_exp << 10) | h_mant; +} + +static float fp16_to_float(uint16_t h) +{ + uint32_t sign = (h >> 15) & 1; + uint32_t exp = (h >> 10) & 0x1f; + uint32_t mant = h & 0x3ff; + + uint32_t bits; + + if (exp == 0) { + if (mant == 0) { + bits = sign << 31; + } else { + float v = mant / 1024.0f; + v = std::ldexp(v, -14); + return sign ? -v : v; + } + } else if (exp == 31) { + bits = (sign << 31) | 0x7f800000 | (mant << 13); + } else { + uint32_t fexp = exp - 15 + 127; + bits = (sign << 31) | (fexp << 23) | (mant << 13); + } + + float v; + std::memcpy(&v, &bits, sizeof(v)); + return v; +} + +static float iou(const Face& a, const Face& b) +{ + float xx1 = std::max(a.x1, b.x1); + float yy1 = std::max(a.y1, b.y1); + float xx2 = std::min(a.x2, b.x2); + float yy2 = std::min(a.y2, b.y2); + + float w = std::max(0.0f, xx2 - xx1); + float h = std::max(0.0f, yy2 - yy1); + float inter = w * h; + + float area_a = std::max(0.0f, a.x2-a.x1) * + std::max(0.0f, a.y2-a.y1); + + float area_b = std::max(0.0f, b.x2-b.x1) * + std::max(0.0f, b.y2-b.y1); + + return inter / (area_a + area_b - inter + 1e-6f); +} + +static void nms(std::vector& faces, float threshold) +{ + std::sort(faces.begin(), faces.end(), + [](const Face& a, const Face& b) { + return a.score > b.score; + }); + + std::vector result; + + for (const auto& f : faces) { + bool keep = true; + + for (const auto& r : result) { + if (iou(f, r) > threshold) { + keep = false; + break; + } + } + + if (keep) + result.push_back(f); + } + + faces.swap(result); +} + +struct Image { + int w = 0; + int h = 0; + std::vector rgb; +}; + +static bool normalize_rotation( + int rotation_degrees, + int& normalized_rotation) +{ + int value = rotation_degrees % 360; + + if (value < 0) + value += 360; + + if ( + value != 0 + && value != 90 + && value != 180 + && value != 270 + ) { + return false; + } + + normalized_rotation = value; + return true; +} + +static Image rotate_image( + const Image& source, + int rotation_degrees) +{ + if (rotation_degrees == 0) + return source; + + Image destination; + + if ( + rotation_degrees == 90 + || rotation_degrees == 270 + ) { + destination.w = source.h; + destination.h = source.w; + } else { + destination.w = source.w; + destination.h = source.h; + } + + destination.rgb.resize( + static_cast(destination.w) + * static_cast(destination.h) + * 3); + + for (int source_y = 0; source_y < source.h; ++source_y) { + for (int source_x = 0; source_x < source.w; ++source_x) { + int destination_x = 0; + int destination_y = 0; + + switch (rotation_degrees) { + case 90: + destination_x = source.h - 1 - source_y; + destination_y = source_x; + break; + + case 180: + destination_x = source.w - 1 - source_x; + destination_y = source.h - 1 - source_y; + break; + + case 270: + destination_x = source_y; + destination_y = source.w - 1 - source_x; + break; + + default: + destination_x = source_x; + destination_y = source_y; + break; + } + + const std::size_t source_offset = + ( + static_cast(source_y) + * static_cast(source.w) + + static_cast(source_x) + ) * 3; + + const std::size_t destination_offset = + ( + static_cast(destination_y) + * static_cast(destination.w) + + static_cast(destination_x) + ) * 3; + + destination.rgb[destination_offset + 0] = + source.rgb[source_offset + 0]; + + destination.rgb[destination_offset + 1] = + source.rgb[source_offset + 1]; + + destination.rgb[destination_offset + 2] = + source.rgb[source_offset + 2]; + } + } + + return destination; +} + +static bool load_image(const std::string& path, Image& img) +{ + int c = 0; + + unsigned char* p = + stbi_load(path.c_str(), &img.w, &img.h, &c, 3); + + if (!p) { + std::cerr << "Errore caricamento: " << path << "\n"; + return false; + } + + img.rgb.assign(p, p + img.w * img.h * 3); + stbi_image_free(p); + + return true; +} + +static void resize_bilinear_rgb( + const Image& src, + std::vector& dst, + int dw, + int dh) +{ + dst.resize(dw * dh * 3); + + float sx = (float)src.w / dw; + float sy = (float)src.h / dh; + + for (int y = 0; y < dh; ++y) { + float fy = (y + 0.5f) * sy - 0.5f; + int y0 = (int)std::floor(fy); + float wy = fy - y0; + + if (y0 < 0) { + y0 = 0; + wy = 0; + } + + int y1 = std::min(y0 + 1, src.h - 1); + + for (int x = 0; x < dw; ++x) { + float fx = (x + 0.5f) * sx - 0.5f; + int x0 = (int)std::floor(fx); + float wx = fx - x0; + + if (x0 < 0) { + x0 = 0; + wx = 0; + } + + int x1 = std::min(x0 + 1, src.w - 1); + + for (int c = 0; c < 3; ++c) { + float p00 = src.rgb[(y0*src.w+x0)*3+c]; + float p01 = src.rgb[(y0*src.w+x1)*3+c]; + float p10 = src.rgb[(y1*src.w+x0)*3+c]; + float p11 = src.rgb[(y1*src.w+x1)*3+c]; + + float v = + p00 * (1-wx) * (1-wy) + + p01 * wx * (1-wy) + + p10 * (1-wx) * wy + + p11 * wx * wy; + + dst[(y*dw+x)*3+c] = + (uint8_t)std::clamp((int)std::round(v), 0, 255); + } + } + } +} + +class Scrfd { +public: + rknn_context ctx = 0; + + bool init(const char* model) + { + FILE* fp = fopen(model, "rb"); + if (!fp) { + perror(model); + return false; + } + + fseek(fp, 0, SEEK_END); + size_t size = ftell(fp); + fseek(fp, 0, SEEK_SET); + + std::vector data(size); + fread(data.data(), 1, size, fp); + fclose(fp); + + int ret = rknn_init(&ctx, data.data(), size, 0, nullptr); + if (ret != 0) { + std::cerr << "rknn_init SCRFD failed: " << ret << "\n"; + return false; + } + + return true; + } + + std::vector detect(const Image& image) + { + float scale = + std::min(640.0f / image.w, 640.0f / image.h); + + int rw = (int)std::round(image.w * scale); + int rh = (int)std::round(image.h * scale); + + std::vector resized; + resize_bilinear_rgb(image, resized, rw, rh); + + std::vector input( + SCRFD_SIZE * SCRFD_SIZE * 3); + + for (int y = 0; y < rh; ++y) { + for (int x = 0; x < rw; ++x) { + int dst = (y * SCRFD_SIZE + x) * 3; + int src = (y * rw + x) * 3; + + for (int c = 0; c < 3; ++c) { + float v = + ((float)resized[src+c] - 127.5f) / 128.0f; + + input[dst+c] = float_to_fp16(v); + } + } + } + + rknn_input in{}; + in.index = 0; + in.type = RKNN_TENSOR_FLOAT16; + in.fmt = RKNN_TENSOR_NHWC; + in.size = input.size() * sizeof(uint16_t); + in.buf = input.data(); + in.pass_through = 1; + + int ret = rknn_inputs_set(ctx, 1, &in); + if (ret != 0) { + std::cerr << "SCRFD inputs_set failed\n"; + return {}; + } + + ret = rknn_run(ctx, nullptr); + if (ret != 0) { + std::cerr << "SCRFD run failed\n"; + return {}; + } + + rknn_output outputs[9]{}; + + for (int i = 0; i < 9; ++i) { + outputs[i].want_float = 0; + } + + ret = rknn_outputs_get(ctx, 9, outputs, nullptr); + if (ret != 0) { + std::cerr << "SCRFD outputs_get failed\n"; + return {}; + } + + const int strides[3] = {8, 16, 32}; + const int counts[3] = {12800, 3200, 800}; + + std::vector faces; + + const float score_threshold = 0.50f; + + for (int level = 0; level < 3; ++level) { + const int stride = strides[level]; + const int count = counts[level]; + + const uint16_t* scores = + (const uint16_t*)outputs[level].buf; + + const uint16_t* bbox = + (const uint16_t*)outputs[3 + level].buf; + + const uint16_t* kps = + (const uint16_t*)outputs[6 + level].buf; + + int feat_w = SCRFD_SIZE / stride; + + for (int i = 0; i < count; ++i) { + float score = fp16_to_float(scores[i]); + + if (score < score_threshold) + continue; + + int anchor = i % 2; + int p = i / 2; + + int gx = p % feat_w; + int gy = p / feat_w; + + float cx = gx * stride; + float cy = gy * stride; + + /* + * AnchorGenerator con due anchor: + * centers duplicati sullo stesso punto. + */ + + (void)anchor; + + float l = fp16_to_float(bbox[i*4+0]) * stride; + float t = fp16_to_float(bbox[i*4+1]) * stride; + float r = fp16_to_float(bbox[i*4+2]) * stride; + float b = fp16_to_float(bbox[i*4+3]) * stride; + + Face f; + f.score = score; + + f.x1 = (cx - l) / scale; + f.y1 = (cy - t) / scale; + f.x2 = (cx + r) / scale; + f.y2 = (cy + b) / scale; + + for (int j = 0; j < 5; ++j) { + float x = + cx + fp16_to_float(kps[i*10+j*2]) + * stride; + + float y = + cy + fp16_to_float(kps[i*10+j*2+1]) + * stride; + + f.kps[j].x = x / scale; + f.kps[j].y = y / scale; + } + + f.x1 = std::clamp(f.x1, 0.0f, (float)image.w); + f.y1 = std::clamp(f.y1, 0.0f, (float)image.h); + f.x2 = std::clamp(f.x2, 0.0f, (float)image.w); + f.y2 = std::clamp(f.y2, 0.0f, (float)image.h); + + faces.push_back(f); + } + } + + rknn_outputs_release(ctx, 9, outputs); + + nms(faces, 0.45f); + + return faces; + } + + ~Scrfd() + { + if (ctx) + rknn_destroy(ctx); + } +}; + +static const Point sface_template[5] = { + {38.2946f, 51.6963f}, + {73.5318f, 51.5014f}, + {56.0252f, 71.7366f}, + {41.5493f, 92.3655f}, + {70.7299f, 92.2041f} +}; + +/* + * Trova la trasformazione similarity: + * + * x' = a*x - b*y + tx + * y' = b*x + a*y + ty + */ +static void similarity_transform( + const Point src[5], + float& a, + float& b, + float& tx, + float& ty) +{ + double sx = 0, sy = 0; + double dx = 0, dy = 0; + + for (int i = 0; i < 5; ++i) { + sx += src[i].x; + sy += src[i].y; + dx += sface_template[i].x; + dy += sface_template[i].y; + } + + sx /= 5; + sy /= 5; + dx /= 5; + dy /= 5; + + double num_a = 0; + double num_b = 0; + double den = 0; + + for (int i = 0; i < 5; ++i) { + double x = src[i].x - sx; + double y = src[i].y - sy; + + double X = sface_template[i].x - dx; + double Y = sface_template[i].y - dy; + + num_a += x*X + y*Y; + num_b += x*Y - y*X; + den += x*x + y*y; + } + + a = (float)(num_a / den); + b = (float)(num_b / den); + + tx = (float)(dx - a*sx + b*sy); + ty = (float)(dy - b*sx - a*sy); +} + +static std::vector align_face( + const Image& image, + const Face& face) +{ + float a, b, tx, ty; + + similarity_transform(face.kps, a, b, tx, ty); + + /* + * Inversa della similarity transform. + * + * src = A^-1 * (dst - t) + */ + float denom = a*a + b*b; + + std::vector output( + SFACE_SIZE * SFACE_SIZE * 3); + + for (int y = 0; y < SFACE_SIZE; ++y) { + for (int x = 0; x < SFACE_SIZE; ++x) { + + float X = x - tx; + float Y = y - ty; + + float sx = + (a*X + b*Y) / denom; + + float sy = + (-b*X + a*Y) / denom; + + int x0 = (int)std::floor(sx); + int y0 = (int)std::floor(sy); + + float wx = sx - x0; + float wy = sy - y0; + + uint8_t rgb[3] = {0,0,0}; + + if (x0 >= 0 && + y0 >= 0 && + x0 + 1 < image.w && + y0 + 1 < image.h) { + + int x1 = x0 + 1; + int y1 = y0 + 1; + + for (int c = 0; c < 3; ++c) { + float p00 = + image.rgb[(y0*image.w+x0)*3+c]; + + float p01 = + image.rgb[(y0*image.w+x1)*3+c]; + + float p10 = + image.rgb[(y1*image.w+x0)*3+c]; + + float p11 = + image.rgb[(y1*image.w+x1)*3+c]; + + float v = + p00*(1-wx)*(1-wy) + + p01*wx*(1-wy) + + p10*(1-wx)*wy + + p11*wx*wy; + + rgb[c] = + (uint8_t)std::clamp( + (int)std::round(v), 0, 255); + } + } + + int idx = (y*SFACE_SIZE+x)*3; + + /* + * SFace OpenCV: + * blobFromImage(..., swapRB=true, scalefactor=1) + * + * Noi partiamo già da RGB, quindi non facciamo + * nessun ulteriore swap e nessuna normalizzazione. + */ + output[idx+0] = float_to_fp16(rgb[0]); + output[idx+1] = float_to_fp16(rgb[1]); + output[idx+2] = float_to_fp16(rgb[2]); + } + } + + return output; +} + +class SFace { +public: + rknn_context ctx = 0; + + bool init(const char* model) + { + FILE* fp = fopen(model, "rb"); + if (!fp) { + perror(model); + return false; + } + + fseek(fp, 0, SEEK_END); + size_t size = ftell(fp); + fseek(fp, 0, SEEK_SET); + + std::vector data(size); + fread(data.data(), 1, size, fp); + fclose(fp); + + int ret = rknn_init(&ctx, data.data(), size, 0, nullptr); + + if (ret != 0) { + std::cerr << "rknn_init SFace failed: " + << ret << "\n"; + return false; + } + + return true; + } + + std::vector feature( + const std::vector& input) + { + rknn_input in{}; + + in.index = 0; + in.type = RKNN_TENSOR_FLOAT16; + in.fmt = RKNN_TENSOR_NHWC; + in.size = input.size() * sizeof(uint16_t); + in.buf = (void*)input.data(); + in.pass_through = 1; + + int ret = rknn_inputs_set(ctx, 1, &in); + + if (ret != 0) { + std::cerr << "SFace inputs_set failed\n"; + return {}; + } + + ret = rknn_run(ctx, nullptr); + + if (ret != 0) { + std::cerr << "SFace run failed\n"; + return {}; + } + + rknn_output output{}; + + output.want_float = 0; + + ret = rknn_outputs_get( + ctx, 1, &output, nullptr); + + if (ret != 0) { + std::cerr << "SFace outputs_get failed\n"; + return {}; + } + + const uint16_t* p = + (const uint16_t*)output.buf; + + std::vector emb(EMBED_DIM); + + for (int i = 0; i < EMBED_DIM; ++i) + emb[i] = fp16_to_float(p[i]); + + rknn_outputs_release(ctx, 1, &output); + + /* + * L2 normalization. + */ + double norm = 0; + + for (float v : emb) + norm += (double)v * v; + + norm = std::sqrt(norm); + + if (norm > 0) { + for (float& v : emb) + v /= (float)norm; + } + + return emb; + } + + ~SFace() + { + if (ctx) + rknn_destroy(ctx); + } +}; + +static std::vector process_image( + const Image& image, + Scrfd& detector, + SFace& recognizer) +{ + auto faces = detector.detect(image); + + for (auto& face : faces) { + auto aligned = align_face(image, face); + face.embedding = + recognizer.feature(aligned); + } + + return faces; +} + +struct FaceEngine::Impl { + Scrfd detector; + SFace recognizer; + bool initialized = false; +}; + +FaceEngine::FaceEngine() + : impl_(new Impl()) +{ +} + +FaceEngine::~FaceEngine() +{ + delete impl_; +} + +bool FaceEngine::init( + const std::string& scrfd_model_path, + const std::string& sface_model_path) +{ + if (impl_->initialized) + return true; + + if (!impl_->detector.init(scrfd_model_path.c_str())) + return false; + + if (!impl_->recognizer.init(sface_model_path.c_str())) + return false; + + impl_->initialized = true; + return true; +} + +bool FaceEngine::process_image( + const std::string& image_path, + std::vector& faces, + int& image_width, + int& image_height, + int rotation_degrees) +{ + faces.clear(); + image_width = 0; + image_height = 0; + + if (!impl_->initialized) { + std::cerr << "FaceEngine non inizializzato\n"; + return false; + } + + int normalized_rotation = 0; + + if (!normalize_rotation( + rotation_degrees, + normalized_rotation)) { + std::cerr + << "Rotazione non valida: " + << rotation_degrees + << ". Valori ammessi: 0, 90, 180, 270\n"; + + return false; + } + + Image source_image; + + if (!load_image(image_path, source_image)) + return false; + + Image oriented_image = rotate_image( + source_image, + normalized_rotation); + + image_width = oriented_image.w; + image_height = oriented_image.h; + + faces = ::process_image( + oriented_image, + impl_->detector, + impl_->recognizer); + + for (const Face& face : faces) { + if (face.embedding.size() != EMBED_DIM) { + std::cerr + << "Embedding non valido per " + << image_path + << ": attesi " + << EMBED_DIM + << " valori, trovati " + << face.embedding.size() + << "\n"; + + faces.clear(); + return false; + } + } + + return true; +} + diff --git a/docs/face-scan-source/src/face_knn.cc b/docs/face-scan-source/src/face_knn.cc new file mode 100644 index 0000000..1b58ed8 --- /dev/null +++ b/docs/face-scan-source/src/face_knn.cc @@ -0,0 +1,781 @@ +#include "knn_opencl.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + + +class ScopedStdoutToStderr { +public: + ScopedStdoutToStderr() + { + std::cout.flush(); + std::fflush(stdout); + + saved_stdout_ = + ::dup(STDOUT_FILENO); + + if (saved_stdout_ < 0) { + throw std::runtime_error( + "Impossibile salvare stdout" + ); + } + + if ( + ::dup2( + STDERR_FILENO, + STDOUT_FILENO + ) < 0 + ) { + ::close(saved_stdout_); + saved_stdout_ = -1; + + throw std::runtime_error( + "Impossibile deviare stdout" + ); + } + } + + ~ScopedStdoutToStderr() + { + if (saved_stdout_ < 0) { + return; + } + + std::cout.flush(); + std::fflush(stdout); + + ::dup2( + saved_stdout_, + STDOUT_FILENO + ); + + ::close(saved_stdout_); + } + + ScopedStdoutToStderr( + const ScopedStdoutToStderr& + ) = delete; + + ScopedStdoutToStderr& operator=( + const ScopedStdoutToStderr& + ) = delete; + +private: + int saved_stdout_ = -1; +}; + + +struct Arguments { + std::string queries_path; + + std::string database_path; + + std::string self_map_path; + + std::string backend = "cpu"; + + std::size_t query_count = 0; + + std::size_t database_count = 0; + + std::uint32_t dimension = 128; + + std::uint32_t top_k = 1; + + float threshold = 0.50f; +}; + + +void print_usage( + const char* program) +{ + std::cerr + << "Uso:\n" + << " " + << program + << " --queries FILE" + << " --database FILE" + << " --query-count N" + << " --database-count N" + << " --dimension N" + << " --top-k N" + << " --backend cpu" + << " [--threshold SCORE]" + << " [--self-map FILE]" + << "\n"; +} + + +std::string require_value( + int argc, + char** argv, + int& index) +{ + if (index + 1 >= argc) { + throw std::invalid_argument( + std::string( + "Valore mancante per " + ) + + argv[index] + ); + } + + index += 1; + + return argv[index]; +} + + +std::size_t parse_size( + const std::string& text, + const char* name) +{ + std::size_t consumed = 0; + + const auto value = + std::stoull( + text, + &consumed, + 10 + ); + + if ( + consumed != text.size() + || value == 0 + ) { + throw std::invalid_argument( + std::string(name) + + " non valido: " + + text + ); + } + + return static_cast( + value + ); +} + + +std::uint32_t parse_u32( + const std::string& text, + const char* name) +{ + const std::size_t value = + parse_size( + text, + name + ); + + if ( + value + > std::numeric_limits< + std::uint32_t + >::max() + ) { + throw std::invalid_argument( + std::string(name) + + " supera uint32" + ); + } + + return static_cast( + value + ); +} + + +float parse_float( + const std::string& text, + const char* name) +{ + std::size_t consumed = 0; + + const float value = + std::stof( + text, + &consumed + ); + + if (consumed != text.size()) { + throw std::invalid_argument( + std::string(name) + + " non valido: " + + text + ); + } + + return value; +} + + +Arguments parse_arguments( + int argc, + char** argv) +{ + Arguments arguments; + + for ( + int index = 1; + index < argc; + ++index + ) { + const std::string option = + argv[index]; + + if ( + option == "--help" + || option == "-h" + ) { + print_usage(argv[0]); + std::exit(0); + } + + if (option == "--queries") { + arguments.queries_path = + require_value( + argc, + argv, + index + ); + } else if ( + option == "--database" + ) { + arguments.database_path = + require_value( + argc, + argv, + index + ); + } else if ( + option == "--self-map" + ) { + arguments.self_map_path = + require_value( + argc, + argv, + index + ); + } else if ( + option == "--backend" + ) { + arguments.backend = + require_value( + argc, + argv, + index + ); + } else if ( + option == "--query-count" + ) { + arguments.query_count = + parse_size( + require_value( + argc, + argv, + index + ), + "query-count" + ); + } else if ( + option == "--database-count" + ) { + arguments.database_count = + parse_size( + require_value( + argc, + argv, + index + ), + "database-count" + ); + } else if ( + option == "--dimension" + ) { + arguments.dimension = + parse_u32( + require_value( + argc, + argv, + index + ), + "dimension" + ); + } else if ( + option == "--top-k" + ) { + arguments.top_k = + parse_u32( + require_value( + argc, + argv, + index + ), + "top-k" + ); + } else if ( + option == "--threshold" + ) { + arguments.threshold = + parse_float( + require_value( + argc, + argv, + index + ), + "threshold" + ); + } else { + throw std::invalid_argument( + "Opzione sconosciuta: " + + option + ); + } + } + + if ( + arguments.queries_path.empty() + || arguments.database_path.empty() + || arguments.query_count == 0 + || arguments.database_count == 0 + ) { + throw std::invalid_argument( + "Argomenti obbligatori mancanti" + ); + } + + if ( + arguments.backend != "cpu" + && arguments.backend != "opencl" + ) { + throw std::invalid_argument( + "Backend non supportato: " + + arguments.backend + ); + } + + return arguments; +} + + +std::vector read_float_file( + const std::string& path, + std::size_t expected_values) +{ + std::ifstream input( + path, + std::ios::binary + ); + + if (!input) { + throw std::runtime_error( + "Impossibile aprire: " + + path + ); + } + + input.seekg( + 0, + std::ios::end + ); + + const std::streamoff byte_count = + input.tellg(); + + input.seekg( + 0, + std::ios::beg + ); + + const auto expected_bytes = + static_cast( + expected_values + * sizeof(float) + ); + + if (byte_count != expected_bytes) { + throw std::runtime_error( + "Dimensione file incoerente: " + + path + + ", byte=" + + std::to_string(byte_count) + + ", attesi=" + + std::to_string( + expected_bytes + ) + ); + } + + std::vector values( + expected_values + ); + + input.read( + reinterpret_cast( + values.data() + ), + byte_count + ); + + if (!input) { + throw std::runtime_error( + "Lettura incompleta: " + + path + ); + } + + return values; +} + + +std::vector read_self_map( + const std::string& path, + std::size_t query_count) +{ + std::vector values( + query_count, + -1 + ); + + if (path.empty()) { + return values; + } + + std::ifstream input(path); + + if (!input) { + throw std::runtime_error( + "Impossibile aprire self-map: " + + path + ); + } + + std::size_t index = 0; + std::int64_t value = -1; + + while (input >> value) { + if (index >= query_count) { + throw std::runtime_error( + "Self-map contiene " + "troppe righe" + ); + } + + values[index] = value; + index += 1; + } + + if (index != query_count) { + throw std::runtime_error( + "Self-map contiene " + + std::to_string(index) + + " righe, attese " + + std::to_string( + query_count + ) + ); + } + + return values; +} + + +void write_json( + const face_knn::SearchOutput& output, + const std::vector& self_map, + float threshold) +{ + std::size_t recognized_count = 0; + + std::cout + << std::setprecision(9) + << "{\n" + << " \"backend\": \"" + << output.backend + << "\",\n" + << " \"device_name\": \"" + << output.device_name + << "\",\n" + << " \"query_count\": " + << output.query_count + << ",\n" + << " \"database_count\": " + << output.database_count + << ",\n" + << " \"dimension\": " + << output.dimension + << ",\n" + << " \"top_k\": " + << output.top_k + << ",\n" + << " \"threshold\": " + << threshold + << ",\n" + << " \"leave_one_out\": " + << ( + self_map.empty() + ? "false" + : "true" + ) + << ",\n" + << " \"elapsed_ms\": " + << output.elapsed_ms + << ",\n" + << " \"results\": [\n"; + + for ( + std::size_t query_index = 0; + query_index < output.query_count; + ++query_index + ) { + auto neighbors = + face_knn::query_neighbors( + output, + query_index + ); + + const std::int64_t excluded = + self_map[query_index]; + + if (excluded >= 0) { + neighbors = + face_knn::exclude_neighbor( + neighbors, + static_cast< + std::uint32_t + >(excluded) + ); + } + + if (neighbors.empty()) { + throw std::runtime_error( + "Nessun vicino disponibile " + "dopo leave-one-out" + ); + } + + const auto& best = + neighbors.front(); + + const bool recognized = + best.score >= threshold; + + if (recognized) { + recognized_count += 1; + } + + std::cout + << " {\n" + << " \"query_index\": " + << query_index + << ",\n" + << " \"excluded_index\": "; + + if (excluded < 0) { + std::cout << "null"; + } else { + std::cout << excluded; + } + + std::cout + << ",\n" + << " \"recognized\": " + << ( + recognized + ? "true" + : "false" + ) + << ",\n" + << " \"best_index\": " + << best.index + << ",\n" + << " \"best_score\": " + << best.score + << ",\n" + << " \"neighbors\": [\n"; + + for ( + std::size_t position = 0; + position < neighbors.size(); + ++position + ) { + const auto& neighbor = + neighbors[position]; + + std::cout + << " {" + << "\"index\": " + << neighbor.index + << ", \"score\": " + << neighbor.score + << "}"; + + if ( + position + 1 + < neighbors.size() + ) { + std::cout << ","; + } + + std::cout << "\n"; + } + + std::cout + << " ]\n" + << " }"; + + if ( + query_index + 1 + < output.query_count + ) { + std::cout << ","; + } + + std::cout << "\n"; + } + + const std::size_t below_count = + output.query_count + - recognized_count; + + const double coverage = + output.query_count > 0 + ? ( + static_cast( + recognized_count + ) + / static_cast( + output.query_count + ) + * 100.0 + ) + : 0.0; + + std::cout + << " ],\n" + << " \"recognized_count\": " + << recognized_count + << ",\n" + << " \"below_threshold_count\": " + << below_count + << ",\n" + << " \"coverage_percent\": " + << coverage + << "\n" + << "}\n"; +} + + +} // namespace + + +int main( + int argc, + char** argv) +{ + try { + const Arguments arguments = + parse_arguments( + argc, + argv + ); + + const std::size_t query_values = + arguments.query_count + * arguments.dimension; + + const std::size_t database_values = + arguments.database_count + * arguments.dimension; + + const auto queries = + read_float_file( + arguments.queries_path, + query_values + ); + + const auto database = + read_float_file( + arguments.database_path, + database_values + ); + + const auto self_map = + read_self_map( + arguments.self_map_path, + arguments.query_count + ); + + face_knn::SearchOptions options; + + options.dimension = + arguments.dimension; + + options.top_k = + arguments.top_k; + + options.kernel_path = + "kernels/knn_float4_topk.cl"; + + face_knn::SearchOutput output; + + if ( + arguments.backend == "opencl" + ) { + /* + * Il runtime Mali scrive la propria versione + * direttamente su stdout. Durante OpenCL + * deviamo stdout su stderr, affinché l'output + * JSON resti puro. + */ + ScopedStdoutToStderr redirect; + + output = + face_knn::search_opencl( + database, + queries, + arguments.database_count, + arguments.query_count, + options + ); + } else { + output = + face_knn::search_cpu( + database, + queries, + arguments.database_count, + arguments.query_count, + options + ); + } + + write_json( + output, + self_map, + arguments.threshold + ); + + return 0; + } catch (const std::exception& error) { + std::cerr + << "[face_knn] errore: " + << error.what() + << "\n"; + + print_usage(argv[0]); + + return 1; + } +} diff --git a/docs/face-scan-source/src/face_recognition.cc b/docs/face-scan-source/src/face_recognition.cc new file mode 100644 index 0000000..893c28c --- /dev/null +++ b/docs/face-scan-source/src/face_recognition.cc @@ -0,0 +1,144 @@ +#include +#include +#include +#include + +#include "face_engine.h" +#include "face_config.h" + +static constexpr float MATCH_THRESHOLD = 0.363f; + +static float cosine( + const std::vector& a, + const std::vector& b) +{ + if (a.size() != b.size()) + return 0.0f; + + float score = 0.0f; + + for (std::size_t i = 0; i < a.size(); ++i) + score += a[i] * b[i]; + + return score; +} + +static void print_faces( + const std::string& image_path, + const std::vector& faces) +{ + std::cout << "\n" << image_path << "\n"; + std::cout << "Faces: " << faces.size() << "\n"; + + for (std::size_t i = 0; i < faces.size(); ++i) { + const Face& face = faces[i]; + + std::cout + << "\nFace " << i + << " score=" << face.score + << "\n bbox=" + << face.x1 << " " + << face.y1 << " " + << face.x2 << " " + << face.y2 + << "\n"; + + std::cout << " landmarks:\n"; + + for (int j = 0; j < 5; ++j) { + std::cout + << " " + << j + << ": " + << face.kps[j].x + << " " + << face.kps[j].y + << "\n"; + } + } +} + +int main(int argc, char** argv) +{ + if (argc != 2 && argc != 3) { + std::cerr + << "Uso:\n" + << " " << argv[0] + << " image.jpg\n" + << " " << argv[0] + << " reference.jpg query.jpg\n"; + + return 1; + } + + const std::string root = + FACE_CLUSTERING_ROOT; + + const std::string scrfd_model = + root + "/models/SCRFD_500M_KPS_640.rknn"; + + const std::string sface_model = + root + "/models/face_recognition_sface_2021dec.rknn"; + + FaceEngine engine; + + if (!engine.init(scrfd_model, sface_model)) + return 1; + + std::vector faces1; + int image1_width = 0; + int image1_height = 0; + + if (!engine.process_image( + argv[1], + faces1, + image1_width, + image1_height)) { + return 1; + } + + print_faces(argv[1], faces1); + + if (argc == 2) + return 0; + + std::vector faces2; + int image2_width = 0; + int image2_height = 0; + + if (!engine.process_image( + argv[2], + faces2, + image2_width, + image2_height)) { + return 1; + } + + print_faces(argv[2], faces2); + + std::cout + << "\n========================================\n" + << "COSINE SIMILARITY\n" + << "========================================\n"; + + for (std::size_t i = 0; i < faces1.size(); ++i) { + for (std::size_t j = 0; j < faces2.size(); ++j) { + const float similarity = cosine( + faces1[i].embedding, + faces2[j].embedding); + + std::cout + << "ref[" << i + << "] vs query[" << j + << "] = " + << similarity; + + if (similarity >= MATCH_THRESHOLD) + std::cout << " MATCH"; + + std::cout << "\n"; + } + } + + return 0; +} diff --git a/docs/face-scan-source/src/face_scan.cc b/docs/face-scan-source/src/face_scan.cc new file mode 100644 index 0000000..08ddf04 --- /dev/null +++ b/docs/face-scan-source/src/face_scan.cc @@ -0,0 +1,368 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "face_engine.h" + +static constexpr std::size_t EMBEDDING_SIZE = 128; + +static std::filesystem::path executable_path() +{ + std::vector buffer(4096); + + while (true) { + const ssize_t length = readlink( + "/proc/self/exe", + buffer.data(), + buffer.size() - 1); + + if (length < 0) { + throw std::runtime_error( + "Impossibile leggere /proc/self/exe"); + } + + const std::size_t path_length = + static_cast(length); + + if (path_length < buffer.size() - 1) { + buffer[path_length] = '\0'; + + return std::filesystem::path( + buffer.data()); + } + + buffer.resize(buffer.size() * 2); + } +} + +static std::filesystem::path application_root() +{ + /* + * Struttura prevista: + * + * root/ + * bin/face_scan + * lib/librknnrt.so + * models/.rknn + * + * executable_path(): + * root/bin/face_scan + * + * parent_path(): + * root/bin + * + * parent_path().parent_path(): + * root + */ + return executable_path() + .parent_path() + .parent_path(); +} + +static void write_json_string(const std::string& value) +{ + std::cout << '"'; + + for (unsigned char ch : value) { + switch (ch) { + case '"': + std::cout << "\\\""; + break; + + case '\\': + std::cout << "\\\\"; + break; + + case '\b': + std::cout << "\\b"; + break; + + case '\f': + std::cout << "\\f"; + break; + + case '\n': + std::cout << "\\n"; + break; + + case '\r': + std::cout << "\\r"; + break; + + case '\t': + std::cout << "\\t"; + break; + + default: + if (ch < 0x20) { + const char* hex = "0123456789abcdef"; + + std::cout + << "\\u00" + << hex[(ch >> 4) & 0x0f] + << hex[ch & 0x0f]; + } else { + std::cout << static_cast(ch); + } + } + } + + std::cout << '"'; +} + +static bool finite_face(const Face& face) +{ + if (!std::isfinite(face.score) || + !std::isfinite(face.x1) || + !std::isfinite(face.y1) || + !std::isfinite(face.x2) || + !std::isfinite(face.y2)) { + return false; + } + + for (const Point& point : face.kps) { + if (!std::isfinite(point.x) || + !std::isfinite(point.y)) { + return false; + } + } + + if (face.embedding.size() != EMBEDDING_SIZE) + return false; + + for (float value : face.embedding) { + if (!std::isfinite(value)) + return false; + } + + return true; +} + +static void write_face_json( + const Face& face, + std::size_t face_index) +{ + const float width = face.x2 - face.x1; + const float height = face.y2 - face.y1; + + std::cout << "{"; + + std::cout + << "\"face_index\":" + << face_index + << ","; + + std::cout + << "\"score\":" + << face.score + << ","; + + std::cout << "\"bbox\":{"; + + std::cout + << "\"x\":" + << face.x1 + << ","; + + std::cout + << "\"y\":" + << face.y1 + << ","; + + std::cout + << "\"width\":" + << width + << ","; + + std::cout + << "\"height\":" + << height; + + std::cout << "},"; + + std::cout << "\"landmarks\":["; + + for (int i = 0; i < 5; ++i) { + if (i != 0) + std::cout << ","; + + std::cout + << "[" + << face.kps[i].x + << "," + << face.kps[i].y + << "]"; + } + + std::cout << "],"; + + std::cout << "\"embedding\":["; + + for (std::size_t i = 0; + i < face.embedding.size(); + ++i) { + if (i != 0) + std::cout << ","; + + std::cout << face.embedding[i]; + } + + std::cout << "]"; + + std::cout << "}"; +} + +int main(int argc, char** argv) +{ + int rotation_degrees = 0; + const char* image_path = nullptr; + + if (argc == 2) { + image_path = argv[1]; + } else if ( + argc == 4 + && std::string(argv[1]) == "--rotation" + ) { + char* parse_end = nullptr; + + const long parsed_rotation = std::strtol( + argv[2], + &parse_end, + 10); + + if ( + parse_end == argv[2] + || *parse_end != '\0' + || parsed_rotation < -360000 + || parsed_rotation > 360000 + ) { + std::cerr + << "Rotazione non valida: " + << argv[2] + << "\n"; + + return 2; + } + + rotation_degrees = + static_cast(parsed_rotation); + + image_path = argv[3]; + } else { + std::cerr + << "Uso:\n" + << " " << argv[0] + << " image.jpg\n" + << " " << argv[0] + << " --rotation 90 image.jpg\n"; + + return 2; + } + + std::filesystem::path root; + + try { + root = application_root(); + } catch (const std::exception& error) { + std::cerr + << "Impossibile determinare la root applicativa: " + << error.what() + << "\n"; + + return 3; + } + + const std::string scrfd_model = + ( + root + / "models" + / "SCRFD_500M_KPS_640.rknn" + ).string(); + + const std::string sface_model = + ( + root + / "models" + / "face_recognition_sface_2021dec.rknn" + ).string(); + + FaceEngine engine; + + if (!engine.init(scrfd_model, sface_model)) { + std::cerr + << "Impossibile inizializzare FaceEngine\n"; + + return 3; + } + + std::vector faces; + int image_width = 0; + int image_height = 0; + + if (!engine.process_image( + image_path, + faces, + image_width, + image_height, + rotation_degrees)) { + std::cerr + << "Scansione fallita: " + << image_path + << "\n"; + + return 4; + } + + for (std::size_t i = 0; i < faces.size(); ++i) { + if (!finite_face(faces[i])) { + std::cerr + << "Risultato non valido per face_index=" + << i + << "\n"; + + return 5; + } + } + + std::cout << std::setprecision(9); + + std::cout << "{"; + + std::cout << "\"image\":{"; + + std::cout << "\"path\":"; + write_json_string(image_path); + + std::cout + << ",\"width\":" + << image_width + << ",\"height\":" + << image_height + << ",\"rotation\":" + << rotation_degrees; + + std::cout << "},"; + + std::cout << "\"faces\":["; + + for (std::size_t i = 0; i < faces.size(); ++i) { + if (i != 0) + std::cout << ","; + + write_face_json(faces[i], i); + } + + std::cout << "]"; + + std::cout << "}\n"; + + return 0; +} diff --git a/docs/face-scan-source/src/knn_opencl.cc b/docs/face-scan-source/src/knn_opencl.cc new file mode 100644 index 0000000..031a586 --- /dev/null +++ b/docs/face-scan-source/src/knn_opencl.cc @@ -0,0 +1,484 @@ +#include "knn_opencl.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace face_knn { + +SearchOutput search_opencl_impl( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options); + + +namespace { + +bool better_neighbor( + const Neighbor& left, + const Neighbor& right) +{ + if (left.score != right.score) { + return left.score > right.score; + } + + return left.index < right.index; +} + + +void validate_finite_values( + const std::vector& values, + const char* name) +{ + for ( + std::size_t index = 0; + index < values.size(); + ++index + ) { + if (!std::isfinite(values[index])) { + throw std::invalid_argument( + std::string(name) + + " contiene un valore non finito" + ); + } + } +} + + +std::uint32_t effective_top_k( + std::size_t database_count, + std::uint32_t requested_top_k) +{ + return std::min( + requested_top_k, + static_cast( + database_count + ) + ); +} + +} // namespace + + +void validate_input( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options) +{ + if (options.dimension == 0) { + throw std::invalid_argument( + "dimension deve essere maggiore di zero" + ); + } + + if (options.top_k == 0) { + throw std::invalid_argument( + "top_k deve essere maggiore di zero" + ); + } + + if (options.lanes_per_query == 0) { + throw std::invalid_argument( + "lanes_per_query deve essere maggiore di zero" + ); + } + + if (options.chunk_size == 0) { + throw std::invalid_argument( + "chunk_size deve essere maggiore di zero" + ); + } + + if (database_count == 0) { + throw std::invalid_argument( + "database_count deve essere maggiore di zero" + ); + } + + if (query_count == 0) { + throw std::invalid_argument( + "query_count deve essere maggiore di zero" + ); + } + + if ( + database_count + > std::numeric_limits< + std::uint32_t + >::max() + ) { + throw std::invalid_argument( + "database_count supera uint32" + ); + } + + const std::size_t expected_database = + database_count + * static_cast( + options.dimension + ); + + const std::size_t expected_queries = + query_count + * static_cast( + options.dimension + ); + + if (database.size() != expected_database) { + throw std::invalid_argument( + "dimensione database incoerente" + ); + } + + if (queries.size() != expected_queries) { + throw std::invalid_argument( + "dimensione query incoerente" + ); + } + + validate_finite_values( + database, + "database" + ); + + validate_finite_values( + queries, + "queries" + ); +} + + +SearchOutput search_cpu( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options) +{ + validate_input( + database, + queries, + database_count, + query_count, + options + ); + + const std::uint32_t result_top_k = + effective_top_k( + database_count, + options.top_k + ); + + SearchOutput output; + + output.query_count = query_count; + output.database_count = database_count; + output.dimension = options.dimension; + output.top_k = result_top_k; + output.backend = "cpu"; + output.device_name = "CPU"; + + output.neighbors.resize( + query_count + * static_cast( + result_top_k + ) + ); + + const auto started_at = + std::chrono::steady_clock::now(); + + for ( + std::size_t query_index = 0; + query_index < query_count; + ++query_index + ) { + const float* query = + &queries[ + query_index + * options.dimension + ]; + + std::vector candidates; + + candidates.reserve(database_count); + + for ( + std::size_t database_index = 0; + database_index < database_count; + ++database_index + ) { + const float* database_vector = + &database[ + database_index + * options.dimension + ]; + + float score = 0.0f; + + for ( + std::uint32_t component = 0; + component < options.dimension; + ++component + ) { + score += + database_vector[component] + * query[component]; + } + + candidates.push_back({ + score, + static_cast( + database_index + ), + }); + } + + if ( + result_top_k + < candidates.size() + ) { + std::nth_element( + candidates.begin(), + candidates.begin() + + result_top_k, + candidates.end(), + better_neighbor + ); + } + + std::sort( + candidates.begin(), + candidates.begin() + + result_top_k, + better_neighbor + ); + + const std::size_t output_offset = + query_index + * result_top_k; + + for ( + std::uint32_t position = 0; + position < result_top_k; + ++position + ) { + output.neighbors[ + output_offset + position + ] = candidates[position]; + } + } + + const auto completed_at = + std::chrono::steady_clock::now(); + + output.elapsed_ms = + std::chrono::duration< + double, + std::milli + >( + completed_at - started_at + ).count(); + + return output; +} + + +SearchOutput search_opencl( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options) +{ + return search_opencl_impl( + database, + queries, + database_count, + query_count, + options + ); +} + + +std::vector query_neighbors( + const SearchOutput& output, + std::size_t query_index) +{ + if (query_index >= output.query_count) { + throw std::out_of_range( + "query_index fuori intervallo" + ); + } + + const std::size_t begin = + query_index + * output.top_k; + + const std::size_t end = + begin + + output.top_k; + + if (end > output.neighbors.size()) { + throw std::runtime_error( + "layout SearchOutput incoerente" + ); + } + + return { + output.neighbors.begin() + begin, + output.neighbors.begin() + end, + }; +} + + +std::vector exclude_neighbor( + const std::vector& neighbors, + std::uint32_t excluded_index) +{ + std::vector filtered; + + filtered.reserve(neighbors.size()); + + for (const Neighbor& neighbor : neighbors) { + if (neighbor.index == invalid_index) { + continue; + } + + if (neighbor.index == excluded_index) { + continue; + } + + filtered.push_back(neighbor); + } + + return filtered; +} + + +ValidationResult compare_outputs( + const SearchOutput& expected, + const SearchOutput& actual, + float score_tolerance, + float recognition_threshold) +{ + if (score_tolerance < 0.0f) { + throw std::invalid_argument( + "score_tolerance non valida" + ); + } + + if ( + expected.query_count + != actual.query_count + || expected.database_count + != actual.database_count + || expected.dimension + != actual.dimension + || expected.top_k + != actual.top_k + || expected.neighbors.size() + != actual.neighbors.size() + ) { + throw std::invalid_argument( + "Output kNN non confrontabili" + ); + } + + ValidationResult result; + + result.positions = + expected.neighbors.size(); + + for ( + std::size_t index = 0; + index < result.positions; + ++index + ) { + const Neighbor& expected_neighbor = + expected.neighbors[index]; + + const Neighbor& actual_neighbor = + actual.neighbors[index]; + + if ( + expected_neighbor.index + == actual_neighbor.index + ) { + result.exact_index_matches += 1; + } + + const float error = std::fabs( + expected_neighbor.score + - actual_neighbor.score + ); + + result.maximum_score_error = + std::max( + result.maximum_score_error, + error + ); + } + + for ( + std::size_t query_index = 0; + query_index < expected.query_count; + ++query_index + ) { + const auto expected_neighbors = + query_neighbors( + expected, + query_index + ); + + const auto actual_neighbors = + query_neighbors( + actual, + query_index + ); + + const bool expected_recognized = + !expected_neighbors.empty() + && expected_neighbors.front().score + >= recognition_threshold; + + const bool actual_recognized = + !actual_neighbors.empty() + && actual_neighbors.front().score + >= recognition_threshold; + + if ( + expected_recognized + == actual_recognized + ) { + result.decision_matches += 1; + } + } + + result.exact_indices = + result.exact_index_matches + == result.positions; + + result.decisions_equal = + result.decision_matches + == expected.query_count; + + if ( + result.maximum_score_error + > score_tolerance + ) { + result.decisions_equal = false; + } + + return result; +} + +} // namespace face_knn diff --git a/docs/face-scan-source/src/knn_opencl_backend.cc b/docs/face-scan-source/src/knn_opencl_backend.cc new file mode 100644 index 0000000..46a12c2 --- /dev/null +++ b/docs/face-scan-source/src/knn_opencl_backend.cc @@ -0,0 +1,944 @@ +#define CL_TARGET_OPENCL_VERSION 120 + +#include + +#include "knn_opencl.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace face_knn { +namespace { + +void check_cl( + cl_int status, + const char* operation) +{ + if (status != CL_SUCCESS) { + throw std::runtime_error( + std::string(operation) + + ": OpenCL status " + + std::to_string(status) + ); + } +} + + +std::string device_string( + cl_device_id device, + cl_device_info property) +{ + size_t size = 0; + + check_cl( + clGetDeviceInfo( + device, + property, + 0, + nullptr, + &size + ), + "clGetDeviceInfo size" + ); + + std::string value( + size, + '\0' + ); + + check_cl( + clGetDeviceInfo( + device, + property, + size, + value.data(), + nullptr + ), + "clGetDeviceInfo value" + ); + + while ( + !value.empty() + && value.back() == '\0' + ) { + value.pop_back(); + } + + return value; +} + + +cl_device_id find_mali_device( + std::string& device_name) +{ + cl_uint platform_count = 0; + + check_cl( + clGetPlatformIDs( + 0, + nullptr, + &platform_count + ), + "clGetPlatformIDs count" + ); + + if (platform_count == 0) { + throw std::runtime_error( + "Nessuna piattaforma OpenCL" + ); + } + + std::vector platforms( + platform_count + ); + + check_cl( + clGetPlatformIDs( + platform_count, + platforms.data(), + nullptr + ), + "clGetPlatformIDs list" + ); + + cl_device_id fallback = nullptr; + std::string fallback_name; + + for ( + cl_platform_id platform + : platforms + ) { + cl_uint device_count = 0; + + const cl_int status = + clGetDeviceIDs( + platform, + CL_DEVICE_TYPE_GPU, + 0, + nullptr, + &device_count + ); + + if ( + status != CL_SUCCESS + || device_count == 0 + ) { + continue; + } + + std::vector devices( + device_count + ); + + check_cl( + clGetDeviceIDs( + platform, + CL_DEVICE_TYPE_GPU, + device_count, + devices.data(), + nullptr + ), + "clGetDeviceIDs list" + ); + + for ( + cl_device_id device + : devices + ) { + const std::string name = + device_string( + device, + CL_DEVICE_NAME + ); + + const std::string vendor = + device_string( + device, + CL_DEVICE_VENDOR + ); + + const std::string full_name = + vendor + " / " + name; + + if (fallback == nullptr) { + fallback = device; + fallback_name = full_name; + } + + if ( + name.find("Mali-G610") + != std::string::npos + ) { + device_name = full_name; + return device; + } + } + } + + if (fallback != nullptr) { + device_name = fallback_name; + + std::cerr + << "[face_knn] Mali-G610 non trovata; " + << "uso GPU OpenCL: " + << fallback_name + << "\n"; + + return fallback; + } + + throw std::runtime_error( + "Nessuna GPU OpenCL disponibile" + ); +} + + +std::string read_text_file( + const std::string& path) +{ + std::ifstream input(path); + + if (!input) { + throw std::runtime_error( + "Kernel OpenCL non leggibile: " + + path + ); + } + + const std::string content{ + std::istreambuf_iterator( + input + ), + std::istreambuf_iterator() + }; + + if (content.empty()) { + throw std::runtime_error( + "Kernel OpenCL vuoto: " + + path + ); + } + + return content; +} + + +bool better_neighbor( + const Neighbor& left, + const Neighbor& right) +{ + if (left.score != right.score) { + return left.score > right.score; + } + + return left.index < right.index; +} + + +Neighbor invalid_neighbor() +{ + return { + -std::numeric_limits::infinity(), + invalid_index, + }; +} + + +void merge_top_k( + const std::vector& chunk_indices, + const std::vector& chunk_scores, + std::size_t chunk_offset, + std::size_t query_count, + std::uint32_t top_k, + std::vector& global_result) +{ + for ( + std::size_t query_index = 0; + query_index < query_count; + ++query_index + ) { + const std::size_t result_offset = + query_index + * top_k; + + std::vector candidates; + + candidates.reserve( + static_cast( + top_k + ) * 2 + ); + + for ( + std::uint32_t position = 0; + position < top_k; + ++position + ) { + const Neighbor& current = + global_result[ + result_offset + + position + ]; + + if ( + current.index + != invalid_index + ) { + candidates.push_back( + current + ); + } + } + + for ( + std::uint32_t position = 0; + position < top_k; + ++position + ) { + const std::size_t chunk_position = + result_offset + + position; + + const std::uint32_t local_index = + chunk_indices[ + chunk_position + ]; + + if ( + local_index + == invalid_index + ) { + continue; + } + + candidates.push_back({ + chunk_scores[ + chunk_position + ], + static_cast( + chunk_offset + + local_index + ), + }); + } + + std::sort( + candidates.begin(), + candidates.end(), + better_neighbor + ); + + for ( + std::uint32_t position = 0; + position < top_k; + ++position + ) { + Neighbor value = + invalid_neighbor(); + + if ( + position + < candidates.size() + ) { + value = + candidates[position]; + } + + global_result[ + result_offset + + position + ] = value; + } + } +} + + +struct OpenCLResources { + cl_context context = nullptr; + + cl_command_queue queue = nullptr; + + cl_program program = nullptr; + + cl_kernel kernel = nullptr; + + cl_mem database_buffer = nullptr; + + cl_mem query_buffer = nullptr; + + cl_mem index_buffer = nullptr; + + cl_mem score_buffer = nullptr; + + ~OpenCLResources() + { + if (score_buffer != nullptr) { + clReleaseMemObject( + score_buffer + ); + } + + if (index_buffer != nullptr) { + clReleaseMemObject( + index_buffer + ); + } + + if (query_buffer != nullptr) { + clReleaseMemObject( + query_buffer + ); + } + + if (database_buffer != nullptr) { + clReleaseMemObject( + database_buffer + ); + } + + if (kernel != nullptr) { + clReleaseKernel(kernel); + } + + if (program != nullptr) { + clReleaseProgram(program); + } + + if (queue != nullptr) { + clReleaseCommandQueue(queue); + } + + if (context != nullptr) { + clReleaseContext(context); + } + } +}; + + +std::string build_log( + cl_program program, + cl_device_id device) +{ + size_t size = 0; + + clGetProgramBuildInfo( + program, + device, + CL_PROGRAM_BUILD_LOG, + 0, + nullptr, + &size + ); + + std::string value( + size, + '\0' + ); + + if (size > 0) { + clGetProgramBuildInfo( + program, + device, + CL_PROGRAM_BUILD_LOG, + size, + value.data(), + nullptr + ); + } + + while ( + !value.empty() + && value.back() == '\0' + ) { + value.pop_back(); + } + + return value; +} + +} // namespace + + +SearchOutput search_opencl_impl( + const std::vector& database, + const std::vector& queries, + std::size_t database_count, + std::size_t query_count, + const SearchOptions& options) +{ + validate_input( + database, + queries, + database_count, + query_count, + options + ); + + if ( + query_count + > std::numeric_limits< + std::uint32_t + >::max() + ) { + throw std::invalid_argument( + "query_count supera uint32" + ); + } + + const std::uint32_t top_k = + std::min( + options.top_k, + static_cast( + database_count + ) + ); + + const std::string kernel_path = + options.kernel_path.empty() + ? "kernels/knn_float4_topk.cl" + : options.kernel_path; + + const std::string kernel_source = + read_text_file( + kernel_path + ); + + std::string device_name; + + const cl_device_id device = + find_mali_device( + device_name + ); + + std::cerr + << "[face_knn] OpenCL GPU: " + << device_name + << "\n"; + + OpenCLResources resources; + + cl_int status = CL_SUCCESS; + + resources.context = + clCreateContext( + nullptr, + 1, + &device, + nullptr, + nullptr, + &status + ); + + check_cl( + status, + "clCreateContext" + ); + + resources.queue = + clCreateCommandQueue( + resources.context, + device, + 0, + &status + ); + + check_cl( + status, + "clCreateCommandQueue" + ); + + const char* source_pointer = + kernel_source.c_str(); + + const size_t source_size = + kernel_source.size(); + + resources.program = + clCreateProgramWithSource( + resources.context, + 1, + &source_pointer, + &source_size, + &status + ); + + check_cl( + status, + "clCreateProgramWithSource" + ); + + const std::string build_options = + "-cl-fast-relaxed-math" + " -D DIM=" + + std::to_string( + options.dimension + ) + + " -D TOPK=" + + std::to_string(top_k) + + " -D QUERY_COUNT=" + + std::to_string(query_count) + + " -D LANES_PER_QUERY=" + + std::to_string( + options.lanes_per_query + ); + + status = clBuildProgram( + resources.program, + 1, + &device, + build_options.c_str(), + nullptr, + nullptr + ); + + if (status != CL_SUCCESS) { + throw std::runtime_error( + "clBuildProgram fallito: " + + build_log( + resources.program, + device + ) + ); + } + + resources.kernel = + clCreateKernel( + resources.program, + "knn", + &status + ); + + check_cl( + status, + "clCreateKernel" + ); + + const std::size_t chunk_capacity = + std::min( + options.chunk_size, + database_count + ); + + resources.database_buffer = + clCreateBuffer( + resources.context, + CL_MEM_READ_ONLY, + chunk_capacity + * options.dimension + * sizeof(float), + nullptr, + &status + ); + + check_cl( + status, + "clCreateBuffer database" + ); + + resources.query_buffer = + clCreateBuffer( + resources.context, + CL_MEM_READ_ONLY + | CL_MEM_COPY_HOST_PTR, + queries.size() + * sizeof(float), + const_cast( + queries.data() + ), + &status + ); + + check_cl( + status, + "clCreateBuffer queries" + ); + + const std::size_t result_count = + query_count + * static_cast( + top_k + ); + + std::vector chunk_indices( + result_count, + invalid_index + ); + + std::vector chunk_scores( + result_count, + -std::numeric_limits::infinity() + ); + + resources.index_buffer = + clCreateBuffer( + resources.context, + CL_MEM_WRITE_ONLY, + result_count + * sizeof(std::uint32_t), + nullptr, + &status + ); + + check_cl( + status, + "clCreateBuffer indices" + ); + + resources.score_buffer = + clCreateBuffer( + resources.context, + CL_MEM_WRITE_ONLY, + result_count + * sizeof(float), + nullptr, + &status + ); + + check_cl( + status, + "clCreateBuffer scores" + ); + + check_cl( + clSetKernelArg( + resources.kernel, + 0, + sizeof( + resources.database_buffer + ), + &resources.database_buffer + ), + "clSetKernelArg database" + ); + + check_cl( + clSetKernelArg( + resources.kernel, + 1, + sizeof( + resources.query_buffer + ), + &resources.query_buffer + ), + "clSetKernelArg queries" + ); + + check_cl( + clSetKernelArg( + resources.kernel, + 2, + sizeof( + resources.index_buffer + ), + &resources.index_buffer + ), + "clSetKernelArg indices" + ); + + check_cl( + clSetKernelArg( + resources.kernel, + 3, + sizeof( + resources.score_buffer + ), + &resources.score_buffer + ), + "clSetKernelArg scores" + ); + + SearchOutput output; + + output.query_count = + query_count; + + output.database_count = + database_count; + + output.dimension = + options.dimension; + + output.top_k = + top_k; + + output.backend = + "opencl"; + + output.device_name = + device_name; + + output.neighbors.assign( + result_count, + invalid_neighbor() + ); + + const std::size_t global_size = + query_count + * options.lanes_per_query; + + const std::size_t local_size = + options.lanes_per_query; + + const std::size_t chunk_count = + ( + database_count + + options.chunk_size + - 1 + ) + / options.chunk_size; + + std::cerr + << "[face_knn] kernel_path=" + << kernel_path + << "\n" + << "[face_knn] build_options=" + << build_options + << "\n" + << "[face_knn] chunk_size=" + << chunk_capacity + << "\n" + << "[face_knn] chunks=" + << chunk_count + << "\n"; + + const auto started_at = + std::chrono::steady_clock::now(); + + for ( + std::size_t chunk_offset = 0; + chunk_offset < database_count; + chunk_offset += options.chunk_size + ) { + const std::size_t chunk_size = + std::min( + options.chunk_size, + database_count + - chunk_offset + ); + + const float* chunk_pointer = + &database[ + chunk_offset + * options.dimension + ]; + + check_cl( + clEnqueueWriteBuffer( + resources.queue, + resources.database_buffer, + CL_TRUE, + 0, + chunk_size + * options.dimension + * sizeof(float), + chunk_pointer, + 0, + nullptr, + nullptr + ), + "clEnqueueWriteBuffer database" + ); + + const std::uint32_t kernel_count = + static_cast( + chunk_size + ); + + check_cl( + clSetKernelArg( + resources.kernel, + 4, + sizeof(kernel_count), + &kernel_count + ), + "clSetKernelArg database count" + ); + + check_cl( + clEnqueueNDRangeKernel( + resources.queue, + resources.kernel, + 1, + nullptr, + &global_size, + &local_size, + 0, + nullptr, + nullptr + ), + "clEnqueueNDRangeKernel" + ); + + check_cl( + clFinish( + resources.queue + ), + "clFinish" + ); + + check_cl( + clEnqueueReadBuffer( + resources.queue, + resources.index_buffer, + CL_TRUE, + 0, + chunk_indices.size() + * sizeof(std::uint32_t), + chunk_indices.data(), + 0, + nullptr, + nullptr + ), + "clEnqueueReadBuffer indices" + ); + + check_cl( + clEnqueueReadBuffer( + resources.queue, + resources.score_buffer, + CL_TRUE, + 0, + chunk_scores.size() + * sizeof(float), + chunk_scores.data(), + 0, + nullptr, + nullptr + ), + "clEnqueueReadBuffer scores" + ); + + merge_top_k( + chunk_indices, + chunk_scores, + chunk_offset, + query_count, + top_k, + output.neighbors + ); + } + + const auto completed_at = + std::chrono::steady_clock::now(); + + output.elapsed_ms = + std::chrono::duration< + double, + std::milli + >( + completed_at - started_at + ).count(); + + return output; +} + + +} // namespace face_knn diff --git a/docs/face_review_endpoints.md b/docs/face_review_endpoints.md new file mode 100644 index 0000000..90a1915 --- /dev/null +++ b/docs/face_review_endpoints.md @@ -0,0 +1,703 @@ +# API Face Review: riepilogo completo degli endpoint + +## Scopo generale + +Le API Face Review gestiscono il flusso completo di revisione dei risultati del riconoscimento facciale: + +- lettura delle run HDBSCAN; +- visualizzazione dei cluster automatici; +- visualizzazione dei volti classificati come noise; +- generazione protetta delle miniature dei volti; +- creazione e rinomina delle persone; +- assegnazione di uno o più cluster alla stessa persona; +- esclusione di singoli volti da una persona; +- assegnazione singola o multipla dei volti non raggruppati; +- conservazione separata del risultato automatico e della decisione manuale. + +Principio fondamentale: + +```text +cluster automatico != persona confermata +``` + +Più cluster possono essere collegati allo stesso `person_id` senza fondere o cancellare i cluster originali. + +## Prefisso e autenticazione + +Tutti gli endpoint descritti usano il prefisso: + +```text +/face-review +``` + +Le route sono protette dal middleware JWT globale. Il client deve inviare: + +```http +Authorization: Bearer +``` + +Il middleware JWT verifica il token e salva il payload in: + +```javascript +req.user +``` + +Il nome dell'utente autenticato è disponibile in: + +```javascript +req.user.name +``` + +### Risoluzione dell'utente visualizzato + +La funzione `resolveFaceReviewUser(req)` applica queste regole: + +- un utente normale senza parametro `user` vede esclusivamente i propri dati; +- Admin può specificare `?user=Fabio` per visualizzare la libreria di un altro utente; +- un utente normale non può selezionare un altro utente; +- cluster, volti, fotografie e persone vengono sempre verificati lato backend. + +Esempi: + +```text +JWT name = Fabio +GET /face-review/context +=> target_user = Fabio + +JWT name = Admin +GET /face-review/context?user=Fabio +=> target_user = Fabio + +JWT name = Fabio +GET /face-review/context?user=Jessica +=> HTTP 403 +``` + +## Endpoint di sola lettura + +### GET /face-review/context + +Restituisce il contesto di autenticazione e la run completata più recente dell'utente visualizzato. + +Richiesta utente normale: + +```http +GET /face-review/context +Authorization: Bearer +``` + +Richiesta Admin: + +```http +GET /face-review/context?user=Fabio +Authorization: Bearer +``` + +Risposta: + +```json +{ + "authenticated_user": "Fabio", + "target_user": "Fabio", + "is_admin": false, + "can_select_user": false, + "latest_run_id": "c44546a7-55a8-423c-b3b3-7dd5da671a1d" +} +``` + +Possibili errori: + +- `401` se il token non è valido o manca l'identità; +- `403` se un utente normale prova a selezionare un altro utente; +- `404` se l'utente richiesto non possiede dati riconoscibili. + +### GET /face-review/runs + +Restituisce la cronologia delle run di clustering per l'utente visualizzato. + +```http +GET /face-review/runs +GET /face-review/runs?user=Fabio +``` + +Ogni run contiene, tra gli altri: + +- `id`; +- `user`; +- `status`; +- `scope`; +- `embedding_count`; +- `min_cluster_size`; +- `min_samples`; +- `metric`; +- `selection_method`; +- `engine` e `engine_version`; +- `cluster_count`; +- `clustered_faces`; +- `noise_faces`; +- `noise_percent`; +- date di avvio e completamento. + +Risposta: + +```json +{ + "user": "Fabio", + "runs": [ + { + "id": "...", + "status": "done", + "embedding_count": 30, + "cluster_count": 5, + "clustered_faces": 14, + "noise_faces": 16 + } + ] +} +``` + +### GET /face-review/clusters + +Restituisce i cluster e i relativi volti. Senza `run_id` usa la run completata più recente. + +```http +GET /face-review/clusters +GET /face-review/clusters?run_id= +GET /face-review/clusters?user=Fabio +``` + +Per ogni cluster restituisce: + +- identificativo e label HDBSCAN; +- dimensione; +- parametri della run; +- eventuale `person_id` e nome della persona; +- elenco dei volti appartenenti al cluster. + +Ogni volto include: + +- `id`; +- `photo_id` e `photo_name`; +- `face_index`; +- detector score; +- probabilità HDBSCAN; +- stato di revisione; +- rotazione; +- URL protetto della miniatura. + +Risposta sintetica: + +```json +{ + "user": "Fabio", + "run": { + "id": "...", + "status": "done" + }, + "clusters": [ + { + "id": "...", + "label": 2, + "size": 4, + "person_id": "...", + "person_name": "Jessica", + "faces": [] + } + ] +} +``` + +### GET /face-review/clusters/:clusterId + +Restituisce un singolo cluster, purché appartenga all'utente autorizzato. + +```http +GET /face-review/clusters/ +``` + +Restituisce i dati del cluster e tutti i suoi volti. Risponde `404` se il cluster non esiste o appartiene a un altro utente. + +### GET /face-review/unclustered + +Restituisce i volti noise della run più recente che non sono ancora assegnati manualmente a una persona. + +La query applica concettualmente: + +```sql +m.noise_flag = 1 +AND f.person_id IS NULL +``` + +Richiesta: + +```http +GET /face-review/unclustered +``` + +Quando un noise viene assegnato a una persona: + +- `cluster_id` resta `NULL`; +- `label` resta `-1`; +- `noise_flag` resta `1`; +- `person_id` viene valorizzato; +- `review_status` diventa `accepted`; +- il volto non compare più in questo endpoint. + +### GET /face-review/persons + +Restituisce le persone dell'utente visualizzato. + +```http +GET /face-review/persons +``` + +Risposta: + +```json +{ + "user": "Fabio", + "persons": [ + { + "id": "...", + "user": "Fabio", + "name": "Jessica", + "created_at": "...", + "updated_at": "..." + } + ] +} +``` + +### GET /face-review/persons/:personId + +Restituisce il dettaglio di una persona e tutti i volti assegnati, inclusi quelli provenienti da cluster e quelli assegnati manualmente dal noise. + +```http +GET /face-review/persons/ +``` + +La risposta include: + +- dati della persona; +- numero totale di volti; +- numero di cluster collegati; +- elenco dei volti; +- cluster di provenienza, se presente; +- miniatura protetta. + +Un volto noise assegnato manualmente viene restituito con: + +```text +cluster_id = null +cluster_label = null +``` + +### GET /face-review/faces/:faceId/thumbnail + +Genera una miniatura WebP protetta del volto. + +```http +GET /face-review/faces//thumbnail +GET /face-review/faces//thumbnail?size=256 +``` + +Dimensione predefinita: + +```text +192 x 192 pixel +``` + +Intervallo accettato per `size`: + +```text +64 <= size <= 512 +``` + +Pipeline dell'immagine: + +1. recupera il volto e la fotografia filtrandoli per utente; +2. risolve il percorso tramite `resolveFilePathForEntryId()`; +3. applica esclusivamente `photos.rotation`; +4. non applica automaticamente l'orientamento EXIF; +5. aggiunge un margine al bounding box; +6. crea un crop quadrato; +7. ridimensiona in WebP. + +Header restituiti: + +```http +Content-Type: image/webp +Cache-Control: private, max-age=3600 +X-Content-Type-Options: nosniff +``` + +Il frontend deve caricare la miniatura tramite `fetch()` con Bearer token e trasformarla in Object URL, perché un normale elemento immagine non aggiunge automaticamente l'header Authorization. + +## Endpoint di scrittura per le persone + +### POST /face-review/persons + +Crea una nuova persona per l'utente visualizzato. + +```http +POST /face-review/persons +Content-Type: application/json +Authorization: Bearer +``` + +Body: + +```json +{ + "name": "Jessica" +} +``` + +Comportamento: + +- normalizza spazi e nome; +- rifiuta nomi vuoti; +- limita il nome a 120 caratteri; +- cerca una persona omonima senza distinguere maiuscole e minuscole; +- se esiste già, restituisce la persona esistente; +- altrimenti crea un UUID e inserisce il record. + +Codici: + +- `201` se la persona è stata creata; +- `200` se il nome esisteva già; +- `400` se il nome è assente o non valido. + +### PATCH /face-review/persons/:personId + +Rinomina una persona. + +```http +PATCH /face-review/persons/ +Content-Type: application/json +Authorization: Bearer +``` + +Body: + +```json +{ + "name": "Nuovo nome" +} +``` + +Comportamento: + +- verifica che la persona appartenga all'utente; +- normalizza e valida il nome; +- blocca duplicati dello stesso utente; +- aggiorna `updated_at`; +- non modifica volti o cluster. + +Possibili errori: + +- `400` nome non valido; +- `404` persona non trovata; +- `409` nome già utilizzato da un'altra persona. + +### DELETE /face-review/persons/:personId/faces/:faceId + +Rimuove un volto da una persona senza cancellare il volto e senza cancellare il cluster automatico. + +```http +DELETE /face-review/persons//faces/ +Authorization: Bearer +``` + +Operazioni transazionali: + +1. verifica persona, volto e utente; +2. imposta `faces.person_id = NULL`; +3. mantiene `faces.cluster_id` invariato; +4. marca la membership come `excluded`; +5. salva `reviewed_by` e `reviewed_at`; +6. inserisce o aggiorna `person_face_exclusions`. + +Un volto rimosso da una persona può essere riassociato successivamente dal cluster o dalla UI appropriata. + +## Endpoint di assegnazione cluster-persona + +### POST /face-review/clusters/:clusterId/assign-person + +Assegna un cluster a una persona esistente e consente di includere o escludere singoli volti. + +```http +POST /face-review/clusters//assign-person +Content-Type: application/json +Authorization: Bearer +``` + +Body: + +```json +{ + "person_id": "", + "included_face_ids": [ + "face:...:0", + "face:...:0" + ] +} +``` + +Comportamento transazionale: + +- verifica che persona e cluster appartengano allo stesso utente; +- verifica che tutti gli ID inclusi appartengano al cluster; +- per i volti inclusi: + - imposta `faces.person_id`; + - marca la membership come `accepted`; + - rimuove eventuali esclusioni precedenti; +- per i volti non inclusi: + - non li assegna alla persona; + - marca la membership come `excluded`; + - registra `person_face_exclusions`; +- imposta `face_clusters.person_id`. + +Questo endpoint permette di collegare più cluster alla stessa persona. Per esempio: + +```text +Cluster 2 -> Jessica +Cluster 3 -> Jessica +Cluster 4 -> Jessica +``` + +I cluster restano distinti e conservano la cronologia HDBSCAN. + +## Endpoint di assegnazione dei noise + +### POST /face-review/faces/:faceId/assign-person + +Assegna manualmente un singolo volto a una persona. + +```http +POST /face-review/faces//assign-person +Content-Type: application/json +Authorization: Bearer +``` + +Body: + +```json +{ + "person_id": "" +} +``` + +Comportamento: + +- verifica persona, volto e utente; +- aggiorna `faces.person_id`; +- non modifica `faces.cluster_id`; +- accetta la membership più recente del volto; +- elimina l'eventuale esclusione precedente dalla stessa persona. + +Per un noise: + +```text +cluster_id resta NULL +label resta -1 +noise_flag resta 1 +review_status diventa accepted +``` + +### POST /face-review/unclustered/assign-person + +Assegna più volti noise selezionati alla stessa persona in una singola transazione. + +Persona esistente: + +```json +{ + "person_id": "", + "face_ids": [ + "face:...:0", + "face:...:1" + ] +} +``` + +Nuova persona: + +```json +{ + "name": "Nuova persona", + "face_ids": [ + "face:...:0", + "face:...:1" + ] +} +``` + +Controlli principali: + +- almeno un volto deve essere selezionato; +- deve essere presente `person_id` oppure `name`; +- usa la run completata più recente; +- tutti i volti devono essere noise della run corrente; +- tutti devono appartenere all'utente; +- tutti devono avere `person_id IS NULL`; +- se un solo volto non è valido, l'intera transazione viene annullata. + +Per ogni volto valido: + +- `faces.person_id` viene impostato; +- `cluster_id` resta invariato, normalmente `NULL`; +- la membership passa ad `accepted`; +- vengono salvati revisore e data; +- eventuali esclusioni pregresse dalla persona vengono eliminate. + +Risposta: + +```json +{ + "ok": true, + "user": "Fabio", + "person": { + "id": "...", + "name": "Jessica" + }, + "person_created": false, + "run_id": "...", + "assigned_face_ids": [], + "assigned_count": 3 +} +``` + +## Tabelle coinvolte + +### face_cluster_runs + +Conserva ogni esecuzione del clustering e i relativi parametri, metriche e stato. + +### face_clusters + +Conserva i cluster automatici. Il campo `person_id` collega il cluster alla persona confermata senza eliminare il cluster. + +### face_cluster_memberships + +Conserva l'appartenenza di ogni volto a una run e a un eventuale cluster. + +Campi rilevanti: + +- `run_id`; +- `cluster_id`; +- `face_id`; +- `label`; +- `probability`; +- `noise_flag`; +- `review_status`; +- `reviewed_by`; +- `reviewed_at`. + +Stati di revisione: + +```text +suggested +accepted +excluded +``` + +### faces + +Conserva bounding box, embedding e stato corrente del volto. + +Distinzione importante: + +- `cluster_id` rappresenta il raggruppamento automatico corrente; +- `person_id` rappresenta la persona confermata manualmente. + +### persons + +Conserva le persone nominate, sempre separate per utente. + +### person_face_exclusions + +Registra che uno specifico volto non appartiene a una specifica persona. Impedisce di perdere la decisione manuale durante successive revisioni. + +## Regole di coerenza + +### Volto clusterizzato e accettato + +```text +cluster_id = valorizzato +person_id = valorizzato +noise_flag = 0 +review_status = accepted +``` + +### Volto noise non revisionato + +```text +cluster_id = NULL +person_id = NULL +label = -1 +noise_flag = 1 +review_status = suggested +``` + +### Volto noise assegnato manualmente + +```text +cluster_id = NULL +person_id = valorizzato +label = -1 +noise_flag = 1 +review_status = accepted +``` + +### Volto escluso da una persona + +```text +person_id = NULL +cluster_id = invariato +review_status = excluded +person_face_exclusions = riga presente +``` + +## Transazioni e sicurezza + +Tutte le operazioni di assegnazione o rimozione importanti vengono eseguite in transazione. Se una validazione o una scrittura fallisce, viene eseguito il rollback completo. + +Le API non accettano percorsi filesystem dal browser. Le miniature partono dal solo `faceId`, recuperano la fotografia dal database e risolvono internamente il file. + +Ogni operazione verifica la coerenza tra: + +```text +utente della run +utente della fotografia +utente della persona +utente autenticato o selezionato da Admin +``` + +## Conversione dei segnaposto in Markdown standard + +Questo file usa segnaposto intenzionali: + +- `§1` al posto di intestazione livello 1; +- `§2` al posto di intestazione livello 2; +- `§3` al posto di intestazione livello 3; +- ``` al posto della recinzione dei blocchi di codice; +- ` al posto dell'apice rovescio inline. + +Esempio di conversione con `sed`, usando caratteri ASCII generati dalla shell: + +```bash +hash=$(printf '\043') +tick=$(printf '\140') + +sed \ + -e "s/^§1 /${hash} /" \ + -e "s/^§2 /${hash}${hash} /" \ + -e "s/^§3 /${hash}${hash}${hash} /" \ + -e "s/```/${tick}${tick}${tick}/g" \ + -e "s/`/${tick}/g" \ + face_review_endpoints_summary.md \ + > face_review_endpoints_standard.md +``` + +Nota: è importante sostituire prima ``` e poi il singolo `, come mostrato nel comando. diff --git a/docs/faces-tabs.md b/docs/faces-tabs.md new file mode 100644 index 0000000..4cbe4c1 --- /dev/null +++ b/docs/faces-tabs.md @@ -0,0 +1,43 @@ + +Risultato finale + +Con questa modifica avremo: + +photos + │ + │ photo_id + ▼ +faces + │ + ├── embedding BLOB (512 byte) + ├── bbox + ├── landmarks + ├── score + ├── cluster_id ────────┐ + └── person_id ──────┐ │ + │ │ + ▼ ▼ + persons + ▲ + │ + face_clusters + +E soprattutto non abbiamo ancora legato artificialmente HDBSCAN all'identità della persona. + +Questo ci lascia libero il passaggio successivo: + +350k embeddings + ↓ +GPU kNN TOPK 32 + ↓ +HDBSCAN + ↓ +face_clusters + ↓ +utente: +cluster 17 → "Mario" +cluster 42 → "Mario" + ↓ +persons.id = Mario + +che è esattamente la separazione che ci serve per l'applicazione. diff --git a/docs/faces.md b/docs/faces.md new file mode 100644 index 0000000..adbd2c3 --- /dev/null +++ b/docs/faces.md @@ -0,0 +1,154 @@ +Perfetto. Ora abbiamo il quadro completo: SQLite tramite better-sqlite3 + Knex. + +Per il tuo caso farei una piccola estensione del DB esistente, senza modificare la struttura di photos. + +Schema che proporrei + +photos + │ + └── faces + │ + ├── embedding BLOB ← 128 × float32 + ├── cluster_id + └── person_id + │ + └── persons + +e separatamente: + +clusters + +Quindi: + +photos 1 ─── N faces N ─── 1 clusters + │ + └──── N ─── 1 persons + +Con una distinzione fondamentale: + +cluster_id = risultato dell'algoritmo HDBSCAN + +person_id = identità assegnata dall'utente + +un person_id può avere più cluster_id + +un nuovo volto riconosciuto può avere person_id ma cluster_id = NULL + + +faces + +Per esempio: + +faces +----- +id +photo_id +x +y +width +height +embedding +cluster_id +person_id +created_at +updated_at + +embedding sarebbe un BLOB SQLite contenente: + +128 × float32 = 512 byte + +Questo è adatto al tuo motore OpenCL: leggiamo il BLOB e lo trasformiamo direttamente in float*. + +clusters + +clusters +-------- +id +run_id +label +size +created_at + +run_id è utile perché HDBSCAN potrebbe essere rieseguito più volte durante la fase iniziale. + +persons + +persons +------- +id +user +name +created_at +updated_at + +Poi aggiungerei una struttura per i prototype embedding, invece di obbligarci ad avere un solo embedding rappresentativo per persona. + +Ad esempio: + +person 7 + ├── prototype 1 → volto frontale + ├── prototype 2 → profilo + ├── prototype 3 → occhiali + └── prototype 4 → barba + +Questo diventa molto utile per il riconoscimento incrementale. + + +--- + +Una scelta importante: niente embedding dei cluster + +Non salverei inizialmente un unico: + +cluster.embedding + +come rappresentazione obbligatoria. + +Abbiamo già tutti i face embedding: + +faces.embedding + +e possiamo calcolare i rappresentanti quando servono. + +Per esempio, dopo che l'utente associa un cluster a una persona, possiamo selezionare alcuni embedding rappresentativi del cluster e inserirli nei prototype della persona. + + +--- + +Quindi il flusso completo diventa + +┌──────────────┐ + │ photos │ + └──────┬───────┘ + │ + face detector + │ + ▼ + ┌──────────────┐ + │ faces │ + │ bbox │ + │ embedding │ + └──────┬───────┘ + │ + ┌──────────┴──────────┐ + │ │ + inizializzazione nuove foto + │ │ + ▼ ▼ + HDBSCAN prototype search + │ │ + ▼ ▼ + clusters person_id + │ + UI utente + │ + ▼ + persons + +E soprattutto questa struttura è compatibile con il benchmark OpenCL che stiamo facendo: il DB rimane il persistente, mentre indices[N×32], distanze, MST ecc. rimangono dati di lavoro GPU/CPU e non devono necessariamente finire in SQLite. + +Prima di scrivere init.js + +Mi manca solo una verifica: hai già codice che estrae i volti / genera gli embedding RKNN? + +Se sì, mandami il file che attualmente gestisce quella parte. Così possiamo progettare faces attorno al formato reale dell'output RKNN e non creare una seconda rappresentazione incompatibile. diff --git a/docs/faces2.md b/docs/faces2.md new file mode 100644 index 0000000..6c255b0 --- /dev/null +++ b/docs/faces2.md @@ -0,0 +1,245 @@ +Perfetto. Ho guardato direttamente il repository e adesso possiamo progettare l'integrazione sul codice reale, non su ipotesi. + +Il repository conferma che la pipeline produce esattamente ciò che ci serve: SCRFD → landmark → alignment 112×112 → SFace → 128 float L2-normalizzati. La Face attuale contiene già score, bbox, 5 landmark e std::vector embedding. + +La cosa più importante + +Non cambierei il modello RKNN. + +Il formato ideale per il DB è già quello che abbiamo: + +float embedding[128] + +Il codice attuale fa anche già la L2 normalization prima di restituire l'embedding. + +Quindi il flusso diventa: + +photos.id + │ + ▼ +SCRFD + │ + ├── bbox + ├── confidence + └── 5 landmarks + │ + ▼ + alignment + │ + ▼ + SFace + │ + ▼ + float[128] + │ + ▼ + faces + │ + ├── HDBSCAN + │ + └── recognition + +E farei una piccola modifica all'architettura del repository + +Attualmente face_recognition.cc è un programma di test: prende una/due immagini e stampa i risultati. Il main() stesso conferma che l'attuale uso è principalmente il confronto fra immagini. + +Non lo trasformerei direttamente nel processo DB. + +Creerei invece qualcosa del genere: + +face-rknn/ +├── src/ +│ ├── face_recognition.cc ← lasciamo il test attuale +│ ├── face_engine.cc +│ ├── face_engine.h +│ └── face_scan.cc ← nuovo scanner DB + +dove FaceEngine espone qualcosa tipo: + +struct FaceResult { + float score; + float x1, y1, x2, y2; + Point kps[5]; + std::array embedding; +}; + +class FaceEngine { +public: + bool init( + const char* scrfd_model, + const char* sface_model + ); + + std::vector process( + const Image& image + ); +}; + +Così il codice RKNN resta riutilizzabile. + + +--- + +DB: ora possiamo definirlo con precisione + +Dato che sei su SQLite/better-sqlite3, userei: + +faces.embedding → BLOB + +con esattamente: + +128 * sizeof(float) += 512 byte + +per volto. + +E non salverei FP16. + +Il modello restituisce FP16, ma il codice attuale li converte già in float e poi normalizza. + +Quindi salviamo il risultato finale: + +float32[128] + +che è esattamente quello che poi il tuo OpenCL può consumare. + +Per 350.000 volti: + +350000 × 512 +≈ 179.2 MB + +assolutamente gestibile. + + +--- + +Una correzione rispetto a quello che ti avevo detto prima + +Avendo visto il codice vero, non introdurrei subito prototypes nel DB. + +Prima farei funzionare questa pipeline: + +photos + ↓ +faces + ↓ +OpenCL kNN + ↓ +HDBSCAN + ↓ +clusters + ↓ +utente assegna persona + ↓ +persons + +Poi, quando implementiamo il riconoscimento incrementale, decidiamo se i prototype debbano essere: + +una tabella persistente; + +un subset di faces; + +oppure un rappresentante/calcolo derivato. + + +Non voglio aggiungere complessità prima che serva. + + +--- + +E c'è un dettaglio molto utile + +Il repository usa già: + +cosine(a, b) + +su embedding L2-normalizzati. + +Quindi il tuo benchmark OpenCL che stiamo facendo è perfettamente allineato: + +cosine(a,b) += +dot(a,b) + +per vettori normalizzati. + +Questa è una buona notizia: non dobbiamo cambiare metrica. + +Il tuo OpenCL può quindi ricevere direttamente i BLOB float32[128] e fare il dot product. + + +--- + +Quindi io procederei così + +Passo 1 — DB + +Aggiungiamo a init.js: + +faces +face_clusters +persons + +senza toccare photos. + +Passo 2 — FaceEngine + +Separiamo detector + SFace dal main() di test. + +Passo 3 — scanner + +Prende: + +photos + +e per ogni foto non ancora processata: + +image + ↓ +faces + ↓ +embedding + ↓ +INSERT faces + +Passo 4 — GPU + +Solo quando abbiamo gli embedding nel DB: + +SQLite + ↓ +float32 array + ↓ +OpenCL + ↓ +kNN TOPK=32 + +Passo 5 — HDBSCAN + +Usiamo quel kNN per costruire: + +core distance +mutual reachability +MST +condensed tree +cluster + +Passo 6 — UI + +L'utente vede i cluster e assegna: + +cluster 123 → "Mario" + +che significa: + +faces.cluster_id = 123 +faces.person_id = Mario + +Poi il sistema incrementale può riconoscere direttamente Mario senza rieseguire HDBSCAN globale. + + +--- + +A questo punto non ti chiederei altro codice alla cieca: abbiamo già DB + backend + face engine + formato embedding. + +Il prossimo passo concreto è che , compatibile con il tuo better-sqlite3, e subito dopo invece di fare soltanto il test da riga di comando. ti preparo **la modifica di `db/init.js` per le tre tabelle**adattiamo `face-rknn` per produrre i record `faces` diff --git a/docs/faces_cmd_files.md b/docs/faces_cmd_files.md new file mode 100644 index 0000000..9562844 --- /dev/null +++ b/docs/faces_cmd_files.md @@ -0,0 +1,69 @@ +s121js/ +├── bin/ +│ └── face_scan +├── lib/ +│ └── librknnrt.so +├── models/ +│ ├── SCRFD_500M_KPS_640.rknn +│ └── face_recognition_sface_2021dec.rknn +├── api_v1/ +│ └── scanner/ +│ └── faceScanner.js +├── db/ +├── tools/ +│ └── scanPhotoFaces.js +└── public/ + + +cd /home/nvme/dockerdata/prove/s122js + +node tools/scanPhotoFaces.js \ + e30a1427708cfc8d63a66d369781b15ac6a07b43e13550b1a7625003914503ed + +node tools/scanPhotoFaces.js \ + 2454c04dd9067d2b5288dcca99c5c3eac85660779a97d6165a08ca7c37481070 + +sqlite3 -header -column \ + api_v1/database.sqlite \ + " + SELECT + photo_id, + COUNT(*) AS face_count, + COUNT(DISTINCT face_index) AS unique_face_indexes, + MIN(length(embedding)) AS min_embedding_bytes, + MAX(length(embedding)) AS max_embedding_bytes + FROM faces + WHERE photo_id = + 'e30a1427708cfc8d63a66d369781b15ac6a07b43e13550b1a7625003914503ed' + GROUP BY photo_id; + " + +[✓] FaceEngine riutilizzabile +[✓] face_scan JSON +[✓] eseguibile relocabile +[✓] runtime RKNN relativo +[✓] modelli relativi +[✓] root server calcolata automaticamente +[✓] conversione embedding in BLOB +[✓] inserimento transazionale +[✓] idempotenza +[✓] rotazione 0/90/180/270 +[✓] validazione rotazione server/motore +[✓] test positivo con due volti +[✓] gestione corretta di zero volti + +photos.rotation + ↓ +validazione Node.js + ↓ +face_scan --rotation 0|90|180|270 + ↓ +rotazione RGB + ↓ +SCRFD + SFace + ↓ +validazione JSON e rotazione applicata + ↓ +transazione SQLite + ↓ +faces.embedding = BLOB float32[128] diff --git a/docs/faces_page.md b/docs/faces_page.md new file mode 100644 index 0000000..5fa8182 --- /dev/null +++ b/docs/faces_page.md @@ -0,0 +1,40 @@ +Obiettivo finale +Scheduler orario + +Continua a fare: + +ogni ora +↓ +clusterUserIfNeeded(Fabio) +clusterUserIfNeeded(Marco) +... + +Apertura faces.html + +Quando viene chiamato: + +GET /face-review/clusters + + +esegue: + +await clusterUserIfNeeded(targetUser); + + +quindi: + +nessuna nuova faccia +↓ +risposta immediata + +oppure + +nuove facce +↓ +clustering +↓ +risposta aggiornata + + +senza F5. + diff --git a/docs/file_lungo.md b/docs/file_lungo.md new file mode 100644 index 0000000..fe96d5d --- /dev/null +++ b/docs/file_lungo.md @@ -0,0 +1,22 @@ +prettier --write public/js/map/mapboxMarkers.js +wc -l public/js/map/mapboxMarkers.js + +sed -n '1,500p' public/js/map/mapboxMarkers.js +sed -n '501,1000p' public/js/map/mapboxMarkers.js +sed -n '1001,1600p' public/js/map/mapboxMarkers.js + +pervstampare a video una funzione scritta come +function myfunction { .... } nel file +questa funzione conta le parentesi graffe +awk ' +/^[[:space:]]*function[[:space:]]+myFunction[[:space:]]*\{/ { + p=1 + n=0 +} +p { + print + n += gsub(/\{/, "{") + n -= gsub(/\}/, "}") + if (n == 0) exit +} +' file diff --git a/docs/map.md b/docs/map.md new file mode 100644 index 0000000..3a70909 --- /dev/null +++ b/docs/map.md @@ -0,0 +1,155 @@ +Sì. Ora che mi hai passato tutte e 3 le parti, possiamo dividerlo davvero in modo funzionale, senza cambiare il comportamento attuale. + +Ti consiglio di non fare semplicemente 3 file da ~500 righe, perché tra poco avrai anche le attività e torneremmo allo stesso problema. + +Struttura che farei + +public/js/map/ +│ +├── mapboxMarkers.js ← ENTRY POINT / API pubblica +│ +├── mapboxState.js ← stato condiviso della mappa +│ +├── mapboxUtils.js ← funzioni comuni +│ +├── photoMarkers.js ← marker foto singole + spiderfy +│ +├── photoClusters.js ← cluster foto HTML + smart zoom +│ +├── mapboxSources.js ← sources/layers Mapbox +│ +└── activityMarkers.js ← attività, da sviluppare + +In questo modo avremo una separazione molto più pulita: + +File Responsabilità + +mapboxMarkers.js Facciata/API pubblica +mapboxState.js mapboxMap, foto, attività, routes, Map dei marker +mapboxUtils.js distanza, placeholder, funzioni comuni +photoMarkers.js foto singole e spiderfy +photoClusters.js cluster fotografici, collage, smart zoom +mapboxSources.js addSource, addLayer, aggiornamento GeoJSON +activityMarkers.js nuova gestione attività + + +E soprattutto eliminiamo una cosa importante + +Nel codice che mi hai mandato ci sono due sistemi di cluster HTML: + +updatePhotoClusterMarkers() + +e + +updatePhotoClusterHtmlMarkers() + +che fanno sostanzialmente la stessa cosa ma in modo leggermente diverso. + +Questo è uno dei motivi per cui il codice sta diventando difficile da mantenere. + +Inoltre nella seconda implementazione c'è questa chiamata: + +handlePhotoClusterClick(clusterFeature); + +mentre la funzione è definita come: + +function handlePhotoClusterClick(clusterId, center) + +Quindi abbiamo anche una piccola incoerenza che conviene sistemare durante la divisione. + +Il pallino blu di Mapbox + +La modifica che hai fatto: + +"circle-opacity": id === "photos" ? 0 : 0.9, +"circle-stroke-opacity": id === "photos" ? 0 : 1, + +la manterrei. + +Quindi: + +Mapbox continua a creare/renderizzare il cluster; + +queryRenderedFeatures() continua a trovarlo; + +il cerchio blu di Mapbox non viene mostrato; + +sopra ci mettiamo il nostro cluster HTML con le miniature. + + +Questo è importante perché se rimuoviamo completamente il layer photos-clusters, il sistema di cluster HTML che hai costruito perde il riferimento ai cluster visibili. + + +--- + +Punto fondamentale per le attività + +Non farei: + +if (id === "photos") { + ... +} + +if (id === "activities") { + ... +} + +dappertutto. + +Meglio avere un sistema generico: + +Mapbox + ├── Photos + │ ├── source + │ ├── clusters + │ ├── markers + │ └── spiderfy + │ + └── Activities + ├── source + ├── clusters + └── markers + +Così in futuro possiamo aggiungere, per esempio: + +activityMarkers.js +activityClusters.js + +senza toccare il sistema delle foto. + + +--- + +Quindi la versione finale sarà concettualmente così + +// mapboxMarkers.js + +import { setMap } from "./mapboxSources.js"; +import { + setPhotos, + clearPhotos, +} from "./photoMarkers.js"; +import { + setActivities, + clearActivities, +} from "./activityMarkers.js"; +import { + setRoutes, +} from "./mapboxSources.js"; + +window.MapboxMarkers = { + setMap, + setPhotos, + setActivities, + setRoutes, +}; + +e tutti i dettagli rimangono nei rispettivi moduli. + +Non cambierei per ora la logica dei cluster/spiderfy più del necessario. Prima facciamo una separazione 1:1 del comportamento attuale, così non rischiamo di perdere i cluster che finalmente funzionano; poi possiamo migliorare il codice internamente. + +L'unica cosa da verificare nel progetto è che questi file vengano caricati come ES modules, cioè con: + + + +Se il tuo progetto è già impostato così, possiamo procedere direttamente con questa struttura. diff --git a/docs/prove_hls.md b/docs/prove_hls.md new file mode 100644 index 0000000..e104bf0 --- /dev/null +++ b/docs/prove_hls.md @@ -0,0 +1,98 @@ +python3 ./hls_cors_server.py /home/orangepi/tmp/hls-bookworm-hevc-full-hw + + +cd /home/nvme/dockerdata/prove/s152js + + +così non ha artefatti + +VIDEO="public/photos/Fabio/original/2017Irlanda19-29ago/VID-20260920-WA0000.mp4" +OUT="/home/orangepi/tmp/hls-hwdecode-swencode-fast" + +rm -rf "$OUT" +mkdir -p "$OUT" + +LD_LIBRARY_PATH=/home/orangepi/mpp-0986d01-bookworm-test \ +/usr/local/bin/ffmpeg \ + -hide_banner \ + -loglevel warning \ + -stats \ + -y \ + -c:v h264_rkmpp \ + -i "$VIDEO" \ + -t 20 \ + -map 0:v:0 \ + -map 0:a:0? \ + -c:v libx264 \ + -preset ultrafast \ + -tune zerolatency \ + -b:v 5000k \ + -maxrate 6000k \ + -bufsize 12000k \ + -r 30 \ + -g 90 \ + -keyint_min 90 \ + -sc_threshold 0 \ + -pix_fmt yuv420p \ + -c:a aac \ + -b:a 160k \ + -ac 2 \ + -ar 48000 \ + -f hls \ + -hls_time 3 \ + -hls_list_size 0 \ + -hls_playlist_type vod \ + -hls_segment_type mpegts \ + -hls_flags independent_segments \ + -hls_segment_filename "$OUT/segment_%05d.ts" \ + "$OUT/master.m3u8" + + + + +VIDEO="public/photos/Fabio/original/2017Irlanda19-29ago/VID_20260221_095917.mp4" +OUT="/home/orangepi/tmp/hls-bookworm-hevc-full-hw" + +rm -rf "$OUT" +mkdir -p "$OUT" + +time env \ + LD_LIBRARY_PATH=/usr/local/lib/mpp-0986d01 \ + /usr/local/bin/ffmpeg \ + -hide_banner \ + -loglevel quiet \ + -nostats \ + -y \ + -c:v hevc_rkmpp \ + -i "$VIDEO" \ + -map 0:v:0 \ + -map 0:a:0? \ + -c:v h264_rkmpp \ + -rc_mode CBR \ + -b:v 5000k \ + -maxrate 6000k \ + -bufsize 12000k \ + -r 30 \ + -g 90 \ + -keyint_min 90 \ + -c:a aac \ + -b:a 160k \ + -ac 2 \ + -ar 48000 \ + -f hls \ + -hls_time 3 \ + -hls_list_size 0 \ + -hls_playlist_type vod \ + -hls_segment_type mpegts \ + -hls_flags independent_segments \ + -hls_segment_filename "$OUT/segment_%05d.ts" \ + "$OUT/master.m3u8" \ + >/dev/null 2>&1 + +time \ +node test-hls.js \ +"/home/nvme/dockerdata/prove/s153js/public/photos/Fabio/original/2017Irlanda19-29ago/VID-20260920-WA0000.mp4" \ +"/home/orangepi/tmp/test-hls-whatsapp-long" + +python3 hls_cors_server.py \ +/home/orangepi/tmp/test-hls-whatsapp-long diff --git a/docs/scanPhoto_how_works.md b/docs/scanPhoto_how_works.md new file mode 100644 index 0000000..2086d59 --- /dev/null +++ b/docs/scanPhoto_how_works.md @@ -0,0 +1,554 @@ + +📌 1. Avvio dello scan +- Parte con un log: "Inizio scan globale per user=X". +- Calcola il percorso della cartella photos//original. +- Conta velocemente quanti file supportati esistono (con countFilesFast). +- Se non ci sono file → termina. + + +📊 2. Gestione dello stato di avanzamento +Durante lo scan: +- Tiene traccia di: + - CURRENT = quanti file ha già processato + - TOTAL_FILES = quanti file totali ci sono +- Aggiorna un file JSON pubblico: + public/photos/scan_status.json + +Questo file contiene: +- current +- total +- percent +- elapsed +- eta + +Serve per mostrare una barra di avanzamento in tempo reale. + + +📁 3. Scansione delle cartelle dell’utente +Per ogni sottocartella dentro original/: +- Chiama scanSingleFolder(user, cartella, absCartella). + +Dentro questa funzione succede il grosso del lavoro. + + +🔍 4. Per ogni file trovato: +scanCartella() restituisce oggetti con: +- id (deterministico) +- name +- relPath +- absPath +- ext +- stat (size, mtime, ecc.) +- path normalizzato + +user=Fabio cartella=2017Irlanda19-29ago relPath=VID_20260317_115145.mp4 id=51a7e1861de8da5147bbbc35d70a0128fcd2a0451c7121a8bf2412cb2903d469 + +Per ogni file: + +🟦 4.1 Recupera eventuale record esistente dal DB +`js +prev = await db("photos").where({ id }).first(); +` + +🟩 4.2 Se il path è cambiato → considerato nuovo +`js +if (prev && prev.path !== f.path) prev = null; +` + +🟢 4.3 Se è un nuovo file +- Log dettagliato +- Chiama processFile() per generare metadati, thumbnails, EXIF, ecc. +- Inserisce nel DB +- Registra un evento in photo_changes +- Aggiunge a newFiles + +🔵 4.4 Fast-skip (file invariato) +Due livelli: + +1. FAST-SIZE-SKIP +Se la size è identica → aggiorna solo path/mtime/hash. + +2. FAST-HASH-SKIP +Se l’hash veloce (sha256(size-mtime)) è identico → aggiorna solo path/mtime. + +🟠 4.5 File modificato +Se non passa i fast-skip: +- Rielabora il file con processFile() +- Aggiorna il DB con .insert().onConflict().merge() +- Registra evento updated +- Aggiunge a newFiles + +--- + +🗑️ 5. Gestione degli orfani +Alla fine della cartella: +- Tutti gli ID presenti nel DB ma non trovati sul disco vengono: + - rimossi dal DB + - rimossi i thumbnails + - loggati come cancellati + +--- + +🌐 6. Invio al server remoto (opzionale) +Se SENDPHOTOS=true e BASEURL è configurato: +- Ogni nuovo file viene inviato via postWithAuth() al server remoto. + +Se non configurato: +- Logga [NO SEND]. + +--- + +🟣 7. Fine scan +Logga il tempo totale e restituisce l’array newFiles. + +--- + +🧩 In sintesi +scanPhoto.js è un motore di sincronizzazione locale → database → server remoto. + +Fa tutto questo: + +| Funzione | Descrizione | +|---------|-------------| +| Scansione ricorsiva | Trova tutti i file supportati nelle cartelle dell’utente | +| Confronto con DB | Determina se un file è nuovo, modificato o invariato | +| Fast-skip | Evita elaborazioni inutili usando size e hash veloce | +| Elaborazione file | Chiama processFile() per EXIF, thumbnails, metadati | +| Aggiornamento DB | Inserisce/aggiorna record in photos | +| Rilevamento orfani | Cancella dal DB file non più presenti sul disco | +| Log avanzamento | Scrive un file JSON con percentuale, ETA, ecc. | +| Invio remoto | Invia i nuovi file a un server remoto (se abilitato) | + +_____ + +functio countFilesFast(rootDir) +conta i file nelle cartelle photos//original + +function formatTime(ms) +trasforma ms in h:m:s + +--- + +async function scanPhoto(dir, userName, db) + +const photosRoot +è il path assoluto /public/photos + +const userDir +è il path assoluto /public/photos//original + +function updateStatusFile() +fa l'aggiornamento dello stato di scansione dei file + +function scanSingleFolder(user, cartella, absCartella) +user: Fabio +cartella: 2017Irlanda19-29ago +absCartella: /home/nvme/dockerdata/prove/s15j/public/photos/Fabio/original/2017Irlanda19-29ago +_____ + + +🧩 Cosa fa esattamente questa riga + +```js +const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db); +``` +--- +Quella riga fa una cosa molto semplice… ma potentissima. +E soprattutto è il cuore della pulizia degli orfani. + + +📌 0. definita da +``` +const createCleanupFunctions = require('./orphanCleanup'); +``` + +🔍 1. Chiama createCleanupFunctions(db) +createCleanupFunctions è una funzione che riceve il database (db) e restituisce un oggetto contenente due funzioni: + +- deleteThumbsById(id) +- deleteFromDB(id, user) + +In pratica, è un factory: crea funzioni già configurate con il DB. + +--- + +📌 2. Cosa fanno le due funzioni restituite + +--- +🗑️ deleteThumbsById(id) +Cancella i thumbnails associati a una foto: + +- thub1 +- thub2 +- eventuali altre miniature generate + +Serve quando un file non esiste più sul disco → quindi i suoi thumbnails diventano spazzatura. + +--- + +🗄️ deleteFromDB(id, user) +Cancella dal database: + +- la riga nella tabella photos +- eventuali metadati collegati +- eventuali record in photo_changes (dipende da come l’hai implementato) + +È la parte che rimuove l’entry dal DB quando il file è sparito dal filesystem. + +--- + +🔴 3. Perché serve? +Durante lo scan, alla fine di ogni cartella, hai: + +`js +for (const orphanId of idsSet) { + log( 🔴 Cancellato ${orphanId}); + await deleteThumbsById(orphanId); + await deleteFromDB(orphanId, user); +} +` + +idsSet contiene gli ID presenti nel DB ma non trovati sul disco. + +Quindi: + +- deleteThumbsById() → pulisce i file fisici +- deleteFromDB() → pulisce il database + +È la logica che mantiene il sistema coerente. + +--- + +🧠 In sintesi +Quella riga: + +`js +const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db); +`Eccoti una spiegazione cristallina e completa di cosa fa: + +`js +async function scanSingleFolder(user, cartella, absCartella) +` + +È una funzione fondamentale: gestisce tutta la sincronizzazione tra filesystem e database per una singola cartella dell’utente. + +--- + +📁 Obiettivo della funzione +Scansionare una cartella (es. DCIM, Vacanze2023, WhatsApp Images) e: + +1. Trovare i file presenti sul disco +2. Confrontarli con il database +3. Capire cosa è nuovo, cosa è modificato, cosa è invariato +4. Aggiornare il DB +5. Elaborare i nuovi file (EXIF, thumbnails, metadati) +6. Rimuovere dal DB i file orfani (presenti nel DB ma non più sul disco) + +È il cuore della logica di sincronizzazione. + +--- + +🧠 Funzionamento passo per passo + +1️⃣ Log di inizio +`js +log(📁 Inizio cartella: ${cartella}); +` + +--- + +2️⃣ Recupera dal DB tutti gli ID già conosciuti +`js +const rows = await db('photos') + .where({ user, cartella }) + .select('id'); + +const idsSet = new Set(rows.map(r => r.id)); +` + +idsSet serve per capire quali file non verranno trovati sul disco → saranno orfani. + +--- + +3️⃣ Itera su tutti i file trovati sul disco +`js +for await (const f of scanCartella(...)) { +` + +scanCartella() restituisce per ogni file: + +- id deterministico +- name +- path normalizzato +- relPath, absPath +- ext +- stat (size, mtime, ecc.) + +--- + +4️⃣ Aggiorna lo stato di avanzamento +Incrementa CURRENT e aggiorna il file JSON con percentuale, ETA, ecc. + +--- + +5️⃣ Recupera eventuale record esistente dal DB +`js +let prev = await db("photos") + .select(...) + .where({ id }) + .first(); +` + +--- + +6️⃣ Calcola il fast-hash +`js +const fastHash = sha256(${st.size}-${st.mtimeMs}); +` + +Serve per capire velocemente se il file è cambiato. + +--- + +🔍 7️⃣ Logica decisionale per ogni file + +🟢 Caso A — File nuovo +Se prev è null: + +- Log +- Chiama processFile() per estrarre metadati, EXIF, thumbnails +- Inserisce nel DB +- Registra evento added +- Aggiunge a newFiles +- Rimuove l’ID da idsSet + +--- + +🔵 Caso B — File invariato (fast-skip) + +1. FAST-SIZE-SKIP +Se la size è identica → file invariato. + +2. FAST-HASH-SKIP +Se l’hash veloce è identico → invariato. + +In entrambi i casi: + +- Aggiorna solo path/mtime/hash nel DB +- Rimuove l’ID da idsSet + +--- + +🟠 Caso C — File modificato +Se non passa i fast-skip: + +- Log +- Chiama processFile() +- Aggiorna il DB con .insert().onConflict().merge() +- Registra evento updated +- Aggiunge a newFiles +- Rimuove l’ID da idsSet + +--- + +🔴 8️⃣ Gestione degli orfani +Alla fine della cartella: + +`js +for (const orphanId of idsSet) { + await deleteThumbsById(orphanId); + await deleteFromDB(orphanId, user); +} +` + +Questi sono file che: + +- esistono nel DB +- non sono stati trovati sul disco + +Quindi vengono eliminati. + +--- + +🧩 In sintesi +scanSingleFolder(): + +| Step | Cosa fa | +|------|---------| +| 1 | Carica dal DB gli ID della cartella | +| 2 | Scansiona i file reali sul disco | +| 3 | Per ogni file decide: nuovo, modificato, invariato | +| 4 | Elabora i nuovi/modificati con processFile() | +| 5 | Aggiorna o inserisce nel DB | +| 6 | Registra gli eventi in photo_changes | +| 7 | Rimuove gli orfani (DB → disco) | +| 8 | Restituisce i nuovi file | + +È la funzione che mantiene coerenza totale tra filesystem e database. + +--- + +Se vuoi, posso anche: + +- disegnarti un diagramma di flusso +- riscrivere la funzione in modo più modulare +- aggiungere logging più leggibile +- ottimizzare la parte di fast-skip +- aggiungere batching per il DB + +Dimmi tu come vuoi procedere. + +fa questo: + +- chiama una funzione factory +- che crea due funzioni già configurate con il DB +- che servono per cancellare foto orfane (thumbnails + DB) + +È un modo elegante per incapsulare la logica di cleanup. + +___ + +Eccoti la spiegazione chiara, completa e tecnica di cosa fa scanCartella.js e cosa restituisce. + +--- + +📌 Cos’è scanCartella? +È una funzione asincrona generator (async function*) che: + +- scansiona ricorsivamente una cartella sul filesystem +- filtra solo i file con estensioni supportate +- calcola un ID deterministico per ogni file +- raccoglie informazioni minime (stat, path, nome, estensione…) +- NON elabora il file (niente EXIF, niente thumbnails) +- NON scrive sul database + +👉 Il suo unico scopo è enumerare i file reali presenti sul disco e restituire un oggetto per ciascuno. + +È volutamente “leggera”: tutta la logica pesante (processFile, DB, confronto) è in scanPhoto. + +--- + +🧠 Cosa fa, passo per passo + +1️⃣ Legge la cartella ricorsivamente +La funzione interna walk(): + +- usa fsp.readdir() con { withFileTypes: true } +- entra nelle sottocartelle +- ignora file non supportati + +--- + +2️⃣ Filtra per estensioni supportate +`js +if (!SUPPORTED_EXTS.has(ext)) continue; +` + +--- + +3️⃣ Costruisce il percorso relativo +`js +const fileRelPath = relPath ? ${relPath}/${e.name} : e.name; +` + +--- + +4️⃣ Genera un ID deterministico +`js +const id = sha256(${userName}/${cartella}/${fileRelPath}); +` + +Questo ID è: + +- stabile +- identico tra scansioni +- basato su user/cartella/percorso relativo + +È fondamentale per confrontare file reali ↔ DB. + +--- + +5️⃣ Legge lo stat del file +`js +st = await fsp.stat(absPath); +` + +Serve per: + +- size +- mtimeMs +- fast-hash +- confronto rapido in scanPhoto + +--- + +6️⃣ Logga il file trovato +`js +log(📂 scanCartella → user=${userName} cartella=${cartella} relPath=${fileRelPath} id=${id}); +` + +--- + +7️⃣ Restituisce un oggetto per ogni file +Usa yield per restituire uno stream di risultati, non un array. + +--- + +📤 Cosa restituisce esattamente? + +Per ogni file valido, restituisce un oggetto: + +`js +{ + id, // ID deterministico + user: userName, // nome utente + cartella, // nome cartella (es. DCIM) + name: e.name, // nome file + relPath: fileRelPath, // percorso relativo dentro la cartella + absPath, // percorso assoluto sul disco + ext, // estensione (es. .jpg) + stat: st, // fs.stat() → size, mtimeMs, ecc. + path: /photos/${userName}/original/${cartella}/${fileRelPath} +} +` + +--- + +🧩 In sintesi + +✔️ Cosa fa +- Scansiona ricorsivamente una cartella +- Filtra solo i file supportati +- Genera ID deterministici +- Legge stat del file +- Costruisce percorsi relativi/assoluti +- Restituisce un oggetto per ogni file + +❌ Cosa NON fa +- Non elabora il file (niente EXIF, niente thumbnails) +- Non scrive nel DB +- Non decide se il file è nuovo/modificato +- Non cancella orfani + +--- + +🎯 A cosa serve? +È il motore di enumerazione dei file. +scanPhoto() usa i dati prodotti da scanCartella() per: + +- confrontare con il DB +- decidere se un file è nuovo/modificato/invariato +- elaborarlo con processFile() +- aggiornare il DB +- eliminare orfani + +--- + +Se vuoi, posso anche: + +- disegnarti un diagramma di flusso +- riscrivere scanCartella in modo più modulare +- aggiungere caching o parallelizzazione +- aggiungere controlli per file corrotti + +Dimmi tu come vuoi procedere. diff --git a/docs/scan_Funzionamento.md b/docs/scan_Funzionamento.md new file mode 100644 index 0000000..1183712 --- /dev/null +++ b/docs/scan_Funzionamento.md @@ -0,0 +1,471 @@ +Perfetto Fabio, ora che il server è sistemato posso spiegarti **esattamente come funziona lo scan adesso**, in modo chiaro, ordinato e soprattutto *vero* rispetto al codice che hai. + +Ti faccio una spiegazione **operativa**, come se stessi guardando il flusso in tempo reale. + +--- + +# 🔵 **1. L’utente chiama `/scan`** +Quando fai: + +``` +GET /scan +``` + +succede questo: + +### ✔ Il middleware JWT autentica l’utente +### ✔ Il middleware GET *non aggiunge più “Common”* +→ quindi lo scan viene fatto **solo per l’utente reale** +→ niente doppie scansioni +→ niente doppio removed + +--- + +# 🔵 **2. Il server chiama `scanPhotosUser(userName, db)`** + +Esempio: + +``` +scanPhotosUser("Fabio", db) +``` + +Questa funzione fa due passaggi: + +--- + +# 🟣 **PASSAGGIO 1 — Conta i file reali (ricorsivo)** +`countFilesUser()`: + +- entra in `/photos/Fabio/original` +- scende in tutte le sottocartelle +- conta solo i file con estensioni supportate +- NON tocca il DB +- NON scansiona due volte +- NON elimina orfani + +Questo serve solo per: + +- mostrare il progresso +- calcolare la percentuale +- stimare il tempo + +E ora funziona correttamente. + +--- + +# 🟣 **PASSAGGIO 2 — Scansiona ogni cartella UNA sola volta** + +Per ogni cartella dentro: + +``` +/photos/Fabio/original/ +``` + +viene chiamato: + +``` +scanPhoto(cartella, userName, db, ...) +``` + +--- + +# 🔵 **3. `scanPhoto()` chiama `scanPhotoCartella()` UNA sola volta** + +E qui avviene la vera magia. + +--- + +# 🟣 **Dentro `scanPhotoCartella()` succede questo:** + +### 1️⃣ Recupera dal DB tutti gli ID della cartella +→ costruisce `idsSet` + +### 2️⃣ Scansiona i file reali con `scanCartella()` +→ per ogni file trovato: +- aggiorna il progresso +- chiama `scanPhotoSingle()` per processarlo +- elimina l’ID da `idsSet` (non è orfano) + +### 3️⃣ Alla fine, gli ID rimasti in `idsSet` sono **orfani veri** + +### 4️⃣ Per ogni orfano: +- elimina thumbs +- elimina dal DB +- registra **1 solo removed** + +--- + +# 🔥 **Perché ora NON hai più 2 removed?** + +Perché: + +- `/scan` viene chiamato **una sola volta** +- `scanPhotosUser` viene eseguito **una sola volta** +- `scanPhoto` viene eseguito **una sola volta** +- `scanPhotoCartella` viene eseguito **una sola volta** +- gli orfani vengono eliminati **una sola volta** + +Prima invece: + +- il middleware GET aggiungeva **Common** +- quindi `/scan` scansionava **Fabio + Common** +- Common vedeva gli ID di Fabio come orfani +→ removed +- Fabio vedeva gli ID come orfani +→ removed +- totale: **2 removed** + +Ora questo non succede più. + +--- + +# 🟢 **RISULTATO FINALE DEL NUOVO SISTEMA** + +### ✔ 1 added quando aggiungi una foto +### ✔ 1 removed quando la togli +### ✔ nessun doppio evento +### ✔ nessuna doppia scansione +### ✔ conteggio file corretto +### ✔ comportamento stabile e identico al vecchio sistema + +--- + +Perfetto Fabio — ti preparo **un diagramma chiaro, leggibile e fedele al comportamento reale del tuo sistema**, così puoi vedere tutto il flusso in un colpo d’occhio. + +Lo faccio in stile **flowchart testuale**, così puoi anche stamparlo o incollarlo nei tuoi appunti. + +--- + +# 🔵 **DIAGRAMMA COMPLETO DELLO SCAN (versione attuale, corretta)** + +``` +┌──────────────────────────────┐ +│ CLIENT /scan │ +└───────────────┬──────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ 1. Middleware JWT │ +│ ✔ Autentica l’utente │ +│ ✔ Imposta req.user │ +└──────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ 2. Middleware GET (PATCH) │ +│ ✔ NON aggiunge più "Common" │ +│ ✔ Per /scan → req.query.user = [utente] │ +└──────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ 3. Route /scan │ +│ chiama: scanPhotosUser(req.user.name, db) │ +└──────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ 4. scanPhotosUser(user) │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 4.1 PRIMA PASSATA (ricorsiva) │ │ +│ │ countFilesUser() │ │ +│ │ ✔ entra in tutte le sottocartelle│ │ +│ │ ✔ conta SOLO i file supportati │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 4.2 SECONDA PASSATA │ │ +│ │ per ogni cartella in /original │ │ +│ │ → scanPhoto(cartella) │ │ +│ └──────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ 5. scanPhoto(cartella) │ +│ chiama: scanPhotoCartella() │ +│ (UNA sola volta per cartella) │ +└──────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ 6. scanPhotoCartella() │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 6.1 Recupera ID dal DB │ │ +│ │ → idsSet = { id1, id2, ... } │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 6.2 Scansione reale dei file │ │ +│ │ for await (f of scanCartella) │ │ +│ │ → scanPhotoSingle(f) │ │ +│ │ → idsSet.delete(f.id) │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 6.3 ORFANI (alla fine) │ │ +│ │ per ogni id rimasto in idsSet: │ │ +│ │ → deleteThumbsById │ │ +│ │ → deleteFromDB │ │ +│ │ → recordChange("removed") │ │ +│ └──────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ 7. scanPhoto ritorna newFiles │ +└──────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ 8. scanPhotosUser aggrega tutti i newFiles │ +└──────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ 9. /scan risponde al client │ +└──────────────────────────────────────────────┘ +``` + +--- + +# 🟢 **Cosa garantisce questo flusso** + +### ✔ `/scan` viene eseguito UNA sola volta +### ✔ `scanPhotosUser` viene eseguito UNA sola volta +### ✔ `scanPhoto` viene eseguito UNA sola volta per cartella +### ✔ `scanPhotoCartella` viene eseguito UNA sola volta +### ✔ gli orfani vengono eliminati UNA sola volta +### ✔ 1 added, 1 removed (mai più 2 removed) +### ✔ conteggio file corretto +### ✔ nessuna interferenza con “Common” + +--- + +# 🔥 Vuoi anche la versione “diagramma a blocchi” o “diagramma a frecce” più compatto? +# Se vuoi, posso anche farti un diagramma visivo del flusso completo, così lo vedi in un colpo d’occhio. + + ┌──────────────┐ + │ /scan │ + └───────┬──────┘ + │ + ▼ + ┌──────────────────────────┐ + │ 1. Autenticazione JWT │ + └──────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────┐ + │ 2. Middleware GET (PATCH applicata) │ + │ → NON aggiunge più "Common" │ + │ → /scan usa SOLO req.user.name │ + └────────────────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────┐ + │ 3. scanPhotosUser(user) │ + └────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────┐ + │ 4. PRIMA PASSATA (ricorsiva) │ + │ countFilesUser() │ + │ → entra in tutte le sottocartelle │ + │ → conta SOLO i file supportati │ + │ → NON tocca DB │ + └──────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────┐ + │ 5. SECONDA PASSATA │ + │ per ogni cartella in /original: │ + │ → scanPhoto(cartella) │ + └──────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────┐ + │ 6. scanPhoto(cartella) │ + │ → chiama scanPhotoCartella() UNA volta │ + └──────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────┐ + │ 7. scanPhotoCartella() │ + │ │ + │ 7.1 Recupera ID dal DB → idsSet │ + │ 7.2 Scansiona file reali (scanCartella) │ + │ → scanPhotoSingle() │ + │ → idsSet.delete(id) │ + │ 7.3 ORFANI (solo alla fine) │ + │ → deleteThumbsById │ + │ → deleteFromDB │ + │ → recordChange("removed") │ + └──────────────────────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────┐ + │ 8. scanPhoto ritorna newFiles │ + └────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────┐ + │ 9. scanPhotosUser aggrega tutti i newFiles │ + └──────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────┐ + │ 10. Risposta al client │ + └──────────────────────────┘ + +Perfetto Fabio — ora che abbiamo integrato **la cancellazione delle cartelle eliminate** e la funzione modulare `deleteFolderForUser()`, ti spiego **esattamente cosa fa /scan adesso**, passo per passo, in modo chiaro e completo. + +Questa è la versione *vera*, aggiornata, precisa del comportamento attuale. + +--- + +# 🔵 **1. L’utente chiama `/scan`** + +### ✔ Se è **Admin** +- scansiona **Common** +- scansiona **tutti gli utenti** presenti in `users.json` + +### ✔ Se è **utente normale** +- scansiona **solo il proprio spazio** + +--- + +# 🔵 **2. Dentro `scanPhotosUser(user)` succede questo** + +## **Fase A — Lettura cartelle reali** +Legge: + +``` +/photos//original/ +``` + +e ottiene la lista delle cartelle *realmente presenti* sul filesystem. + +Esempio: + +``` +["Vacanze2023", "Lavoro", "Gatti"] +``` + +--- + +# 🔵 **3. Rilevamento cartelle eliminate (NOVITÀ)** + +Ora c’è la parte nuova e fondamentale: + +### ✔ Recupera tutte le cartelle presenti nel DB per quell’utente +(es. cartelle che contengono record) + +### ✔ Confronta DB vs filesystem + +Se nel DB c’è: + +``` +["Vacanze2023", "Lavoro", "Gatti", "VecchiaCartella"] +``` + +ma sul disco c’è: + +``` +["Vacanze2023", "Lavoro", "Gatti"] +``` + +allora: + +``` +"VecchiaCartella" è stata cancellata dal filesystem +``` + +### 👉 A questo punto viene chiamata: + +``` +deleteFolderForUser(user, "VecchiaCartella") +``` + +Che fa: + +- elimina **tutti i record DB** della cartella +- registra **removed** per ogni file +- elimina **tutta la cartella thumbs residua**: + +``` +/photos//thumbs/VecchiaCartella/ +``` + +### 🔥 Risultato: +**DB pulito + thumbs puliti** +anche se la cartella originale è stata cancellata manualmente. + +--- + +# 🔵 **4. Fase B — Conteggio file reali** + +Viene eseguita la prima passata ricorsiva: + +``` +countFilesUser() +``` + +Serve solo per: + +- contare i file reali +- calcolare la percentuale +- mostrare il progresso + +Non modifica nulla. + +--- + +# 🔵 **5. Fase C — Scansione vera (UNA sola volta per cartella)** + +Per ogni cartella reale: + +``` +scanPhoto(cartella) +``` + +Che: + +- scansiona i file reali +- aggiorna il DB +- genera “added” +- rileva orfani **solo dentro quella cartella** +- elimina thumbs e DB per gli orfani +- registra “removed” + +--- + +# 🔵 **6. Fine scan** + +Restituisce: + +- lista dei nuovi file +- log completo +- DB aggiornato +- thumbs aggiornati + +--- + +# 🟢 **RIASSUNTO: COSA FA /scan ADESSO** + +### ✔ Scansiona solo cartelle realmente presenti +### ✔ Rileva cartelle eliminate dal filesystem +### ✔ Cancella TUTTI i thumbs residui della cartella eliminata +### ✔ Cancella TUTTI i record DB della cartella eliminata +### ✔ Registra “removed” per ogni file eliminato +### ✔ Scansiona ogni cartella UNA sola volta +### ✔ Nessun doppio removed +### ✔ Nessun orfano nascosto +### ✔ Nessuna doppia passata +### ✔ Nessun conflitto tra utenti +### ✔ Admin può scansionare tutti + +--- + +# 🔥 Vuoi che ti preparo anche un diagramma aggiornato del flusso `/scan` con la nuova logica di cancellazione cartelle? \ No newline at end of file diff --git a/docs/scan_flow.md b/docs/scan_flow.md new file mode 100644 index 0000000..2dc0559 --- /dev/null +++ b/docs/scan_flow.md @@ -0,0 +1,102 @@ + FILE NUOVO + │ + ▼ + WATCHER + │ + ▼ + photo_ingest_jobs + │ + ▼ + PHOTO DAEMON [I] + │ + ▼ + runOneJob() + │ + ▼ + scanPhotoSingle() + │ + ┌─────────┼─────────┐ + │ │ │ + ▼ ▼ ▼ + photos thumbnails geocode_jobs + │ │ + │ ▼ + │ GEOCODE DAEMON [G] + │ + │ + └─ SQLite trigger + │ + ▼ + face_scan_jobs + │ + ▼ + FACE DAEMON [F] + + + SCAN + │ + ▼ + scanner foto + │ + ▼ + scanFile.js + │ + ┌──────┴───────┐ + │ │ + ▼ ▼ + processFile() foto esistente? + │ + ▼ + thumbnails + │ + ▼ + photos DB + │ + ├──────────────► trigger SQLite + │ │ + │ ▼ + │ face_scan_jobs + │ │ + │ ▼ + │ FACE DAEMON + │ + ▼ + photo_changes + added / updated + + + +PHOTO DAEMON + │ + │ foto salvata + thumbnail pronte + │ job = done + ▼ +enqueueServerEvent() + │ + ▼ +server_event_outbox + │ + │ consumer ogni ~250 ms + ▼ +SERVER [S] + │ + ▼ +broadcastPhotoEvent(user, { + type: "added", + id: photoId +}) + │ + ▼ +WebSocket :4002 + │ + ▼ +Browser + │ + ▼ +sync.js + │ + ▼ +enqueueAdded(id) + │ + ▼ +GALLERIA AGGIORNATA diff --git a/docs/scan_flow1.md b/docs/scan_flow1.md new file mode 100644 index 0000000..f6881ff --- /dev/null +++ b/docs/scan_flow1.md @@ -0,0 +1,204 @@ +Sì. Se in questo momento stai usando Scan invece del watcher, il percorso è diverso perché entra in gioco il vecchio scanner che abbiamo già visto, in particolare scanFile.js. + +Da quello che abbiamo verificato, il flusso è questo. + +Flusso Scan attuale + SCAN + │ + ▼ + scanner foto + │ + ▼ + scanFile.js + │ + ┌──────┴───────┐ + │ │ + ▼ ▼ + processFile() foto esistente? + │ + ▼ + thumbnails + │ + ▼ + photos DB + │ + ├──────────────► trigger SQLite + │ │ + │ ▼ + │ face_scan_jobs + │ │ + │ ▼ + │ FACE DAEMON + │ + ▼ + photo_changes + added / updated + + +Questo deriva direttamente dal scanFile.js che abbiamo visto. Per una nuova foto fa: + +await db('photos').insert(...); + +await db('photo_changes').insert({ + photo_id: meta.id, + user, + change_type: 'added', + timestamp: ... +}); + + +e per una foto modificata: + +change_type: 'updated' + +Face + +Anche usando Scan, Face continua a utilizzare il daemon. + +Questo perché non è scanFile.js che avvia Face. È il trigger SQLite che abbiamo verificato: + +scanFile + ↓ +INSERT photos + ↓ +photos_face_enqueue_insert + ↓ +face_scan_jobs + ↓ +FACE DAEMON + + +E per una modifica: + +UPDATE photos + ↓ +photos_face_enqueue_update + ↓ +face_scan_jobs + ↓ +FACE DAEMON + + +Questa parte quindi è già correttamente disaccoppiata. + +Geocode invece va verificato + +Qui c'è una differenza importante. + +Nel nuovo scanPhotoSingle.js abbiamo visto esplicitamente: + +await enqueueGeocodeJob(...) + + +quindi con Photo daemon sappiamo che: + +Photo daemon → geocode_jobs → Geocode daemon + + +Nel pezzo di scanFile.js che abbiamo esaminato non abbiamo invece verificato l'enqueue geocode. + +Quindi non darei ancora per certo che lo Scan utilizzi il nuovo Geocode daemon. + +E la Gallery? + +Anche questa è diversa. + +Il vecchio Scan scrive: + +photo_changes + + +ma abbiamo verificato che attualmente nessuno consuma photo_changes. + +Bisogna però ricordare che diverse route autoScan.js effettuano direttamente: + +wss.broadcastToUser(...) + + +e il nostro grep aveva trovato vari: + +type: "added" + + +dentro autoScan.js. + +Quindi è molto probabile che con lo Scan il percorso realtime sia: + +SCAN + │ + ▼ +scanFile / scanner + │ + ▼ +photos DB + │ + ▼ +autoScan.js + │ + ▼ +broadcastToUser() + │ + ▼ +WebSocket + │ + ▼ +sync.js + │ + ▼ +Gallery + + +e non utilizzi la nuova server_event_outbox, perché Scan viene eseguito dentro il processo server e può accedere direttamente al WebSocket. + +In sostanza oggi abbiamo probabilmente due strade + NUOVA FOTO + │ + ┌────────────┴─────────────┐ + │ │ + SCAN WATCHER + │ │ + ▼ ▼ + scanFile.js photo_ingest_jobs + │ │ + │ PHOTO DAEMON + │ │ + └────────► photos ◄────────┘ + │ + ▼ + FACE trigger + │ + ▼ + FACE DAEMON + + +Per la gallery: + +SCAN + │ + └──► autoScan → WebSocket ──────────────► Gallery + +PHOTO DAEMON + │ + └──► server_event_outbox → Server → WS ─► Gallery + + +Questa doppia strada può essere perfettamente corretta: l'outbox serve solo quando il produttore è un processo esterno al server. + +Se vuoi capire esattamente il flusso Scan senza supposizioni, il prossimo controllo ideale è: + +grep -nE "scanFile|broadcastToUser|type: ['\"]added|type: ['\"]updated|enqueueGeocode" \ + routes/autoScan.js + + +e: + +grep -nE "enqueueGeocode|geocode|broadcast|recordChange" \ + api_v1/scanner/scanFile.js + + +Con questi due output possiamo confrontare precisamente: + +SCAN vs WATCHER + PHOTO DAEMON + + +e verificare se anche Scan usa i daemon Geocode e Face e come aggiorna la galleria. diff --git a/docs/server_cmd.md b/docs/server_cmd.md new file mode 100644 index 0000000..834d982 --- /dev/null +++ b/docs/server_cmd.md @@ -0,0 +1,147 @@ +come utilizzare i comandi +per vedere se il server funziona + +TOKEN=$(curl -s -X POST https://prova.patachina.it/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"fabio@gmail.com","password":"master66"}' | jq -r '.token') + +echo "$TOKEN" + +curl -H "Authorization: Bearer $TOKEN" \ + https://prova.patachina.it/photos/ + +curl -s -H "Authorization: Bearer $TOKEN" https://prova.patachina.it/activities/ + +curl -s \ + -H "Authorization: Bearer $TOKEN" \ + "https://prova.patachina.it/photos/3846bf7e160007e43c6de5a612b3384ad4235276b7ac86284fd2a7e45a3b0a27" | jq + +curl -s \ + -H "Authorization: Bearer $TOKEN" \ + "https://prova.patachina.it/photos/byIds?id=3846bf7e160007e43c6de5a612b3384ad4235276b7ac86284fd2a7e45a3b0a27&user=Fabio" | jq + +curl -s -X POST \ + https://prova.patachina.it/share/create \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"entryId":"3846bf7e160007e43c6de5a612b3384ad4235276b7ac86284fd2a7e45a3b0a27"}' | jq + +curl -s -X POST \ + https://prova.patachina.it/share/base64 \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"entryId":"3846bf7e160007e43c6de5a612b3384ad4235276b7ac86284fd2a7e45a3b0a27"}' | jq + + + + +-------- +/routes/photos.js + +Nel tuo routes/photos.js ci sono queste interrogazioni/operazioni disponibili: + +Metodo Endpoint Cosa fa + +GET /photos Tutte le foto visibili all'utente +GET /photos/:id Una foto specifica per ID +GET /photos/changes?since=... Foto create/modificate dopo un timestamp +GET /photos/byIds?id=...&user=... Cerca una o più foto per ID +GET /photos/deleted_hard?since=... Elenco delle foto eliminate definitivamente +POST /photos Inserisce/aggiorna una foto +POST /photos/initDB Svuota il DB foto dell'utente; Admin tutto +POST /photos/initDBuser Admin: svuota il DB di uno specifico utente +POST /photos/:id/toggle-soft Soft delete / ripristino +DELETE /photos/:id Hard delete della foto + + +Quelle utili per interrogare + +1. Tutte le foto dell'utente + +curl -s -H "Authorization: Bearer $TOKEN" \ + https://prova.patachina.it/photos | jq + +2. Una foto per ID + +curl -s -H "Authorization: Bearer $TOKEN" \ + "https://prova.patachina.it/photos/3846bf7e160007e43c6de5a612b3384ad4235276b7ac86284fd2a7e45a3b0a27" | jq + +3. Foto cambiate dopo una certa data + +Per esempio: + +curl -s -H "Authorization: Bearer $TOKEN" \ + "https://prova.patachina.it/photos/changes?since=2026-08-15T00:00:00.000Z" | jq + +4. Cercare per uno o più ID + +curl -s -H "Authorization: Bearer $TOKEN" \ + "https://prova.patachina.it/photos/byIds?id=3846bf7e160007e43c6de5a612b3384ad4235276b7ac86284fd2a7e45a3b0a27&user=Fabio" | jq + +5. Vedere le eliminazioni definitive da un timestamp + +curl -s -H "Authorization: Bearer $TOKEN" \ + "https://prova.patachina.it/photos/deleted_hard?since=2026-08-15T00:00:00.000Z" | jq + +Una cosa interessante è che non c'è una rotta per filtrare direttamente /photos per cartella, name, taken_at, GPS, ecc.: quelle informazioni vengono restituite, ma il router attuale non espone query tipo ?user=...&cartella=.... + +Se vuoi, posso anche prepararti una lista di tutti i curl pronti da copiare per testare ogni endpoint di photos. + +---------- +python3 callback_server.py + +TOKEN=$(curl -s -X POST https://prova.patachina.it/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"fabio@gmail.com","password":"master66"}' | jq -r '.token') + +echo "$TOKEN" + + +curl -v -X POST "https://prova.patachina.it/hls/start" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "photoId": "e18affb38f030e3bc631457f0ff2ecaf041b0e150d0e87dd8a23c7bb068ca7c1", + "outDir": "fabiocast", + "callbackUrl": "http://192.168.1.4:8765/hls-ready", + "segmentsNeeded": 3 + }' + + + +cd /usr/local/src/ffmpeg-rockchip-test + +rm -rf /tmp/hls_test2 +mkdir -p /tmp/hls_test2 + +/usr/local/bin/ffmpeg \ + -hide_banner \ + -loglevel verbose \ + -y \ + -probesize 5000000 \ + -analyzeduration 10000000 \ + -c:v h264_rkmpp \ + -i /home/orangepi/test_hwdec_rkmpp.mp4 \ + -map 0:v:0 \ + -map 0:a:0? \ + -c:v h264_rkmpp \ + -rc_mode CBR \ + -b:v 5000k \ + -maxrate 6000k \ + -bufsize 12000k \ + -r 30 \ + -g 90 \ + -keyint_min 90 \ + -c:a aac \ + -b:a 160k \ + -ac 2 \ + -ar 48000 \ + -f hls \ + -hls_time 3 \ + -hls_list_size 0 \ + -hls_playlist_type vod \ + -hls_segment_type mpegts \ + -hls_segment_filename '/tmp/hls_test2/segment_%05d.ts' \ + /tmp/hls_test2/master.m3u8 + + diff --git a/docs/start_server_con_clean.md b/docs/start_server_con_clean.md new file mode 100644 index 0000000..5493ef0 --- /dev/null +++ b/docs/start_server_con_clean.md @@ -0,0 +1,263 @@ +Perfetto. Ora il reset è davvero pulito: + +Nessun processo della galleria attivo +Nessun processo usa SQLite +Lock daemon volti assente +Lock daemon prototype assente +database.sqlite assente +WAL e SHM assenti + +1. Ricrea il database vuoto +cd /home/nvme/dockerdata/prove/s137js || exit 1 + +node db/init.js + + +Dovresti vedere la creazione delle tabelle e infine: + +✅ init completato + +2. Verifica subito i conteggi + +Fallo prima di avviare server o daemon: + +sqlite3 -header -column \ + api_v1/database.sqlite \ + " + SELECT + (SELECT COUNT(*) FROM photos) + AS photos, + + (SELECT COUNT(*) FROM faces) + AS faces, + + (SELECT COUNT(*) FROM persons) + AS persons, + + (SELECT COUNT(*) FROM face_scan_jobs) + AS scan_jobs, + + (SELECT COUNT(*) FROM face_cluster_runs) + AS cluster_runs, + + (SELECT COUNT(*) FROM face_prototype_jobs) + AS prototype_jobs, + + (SELECT COUNT(*) FROM person_face_prototypes) + AS prototypes; + " + + +Il risultato deve essere: + +photos faces persons scan_jobs cluster_runs prototype_jobs prototypes +------ ----- ------- --------- ------------ -------------- ---------- +0 0 0 0 0 0 0 + +3. Verifica specificamente le persone +sqlite3 -header -column \ + api_v1/database.sqlite \ + " + SELECT + id, + user, + name, + created_at + FROM persons; + " + + +Deve restituire zero righe. In particolare, Jessica non deve comparire. + +4. Verifica che non esistano vecchie run +sqlite3 -header -column \ + api_v1/database.sqlite \ + " + SELECT + (SELECT COUNT(*) FROM face_cluster_runs) + AS cluster_runs, + + (SELECT COUNT(*) FROM face_clusters) + AS clusters, + + ( + SELECT COUNT(*) + FROM face_cluster_memberships + ) AS memberships, + + ( + SELECT COUNT(*) + FROM person_face_reference_exclusions + ) AS reference_exclusions, + + ( + SELECT COUNT(*) + FROM face_recognition_index_state + ) AS recognition_index_states; + " + + +Atteso: + +cluster_runs 0 +clusters 0 +memberships 0 +reference_exclusions 0 +recognition_index_states 0 + +5. Controlla l’integrità +sqlite3 \ + api_v1/database.sqlite \ + "PRAGMA integrity_check;" + + +Atteso: + +ok + +6. Avvia i processi +Terminale 1: server +cd /home/nvme/dockerdata/prove/s137js || exit 1 + +npm start + + +Controlla nel log: + +DB PATH: /home/nvme/dockerdata/prove/s137js/api_v1/database.sqlite + +Terminale 2: daemon scansione volti +cd /home/nvme/dockerdata/prove/s137js || exit 1 + +npm run face:daemon + +Terminale 3: daemon prototype +cd /home/nvme/dockerdata/prove/s137js || exit 1 + +npm run prototype:daemon + + +All’avvio, il daemon prototype dovrebbe mostrare: + +pending_jobs: 0 + +7. Controlla processi e lock + +Da un quarto terminale: + +pgrep -af \ + 'runFaceScanDaemon|runPrototypeDaemon|server.js' + + +Controlla i lock: + +cd /home/nvme/dockerdata/prove/s137js || exit 1 + +ls -l \ + face-scan-daemon.lock \ + prototype-worker-daemon.lock + + +Entrambi devono esistere mentre i daemon sono attivi. + +8. Verifica nuovamente le persone + +Dopo l’avvio del server e dei daemon: + +sqlite3 -header -column \ + api_v1/database.sqlite \ + " + SELECT + id, + user, + name, + created_at + FROM persons; + " + + +Deve essere ancora vuoto. + +È normale che nel frattempo vengano ricreati automaticamente: + +photos +face_scan_jobs +faces +embedding + + +Non devono invece essere ricreati automaticamente: + +persons +face_cluster_runs +person_face_prototypes +person_face_reference_exclusions +face_prototype_jobs + +9. Attendi il completamento della scansione + +Controlla: + +npm run face:status + + +Quando avrai: + +pending: 0 +processing: 0 +error: 0 + + +verifica gli embedding: + +sqlite3 -header -column \ + api_v1/database.sqlite \ + " + SELECT + (SELECT COUNT(*) FROM photos) + AS photos, + + (SELECT COUNT(*) FROM faces) + AS faces, + + ( + SELECT COUNT(*) + FROM faces + WHERE length(embedding) = 512 + ) AS valid_embeddings, + + ( + SELECT COUNT(*) + FROM persons + ) AS persons, + + ( + SELECT COUNT(*) + FROM face_cluster_runs + ) AS cluster_runs; + " + + +A scansione conclusa è plausibile ottenere nuovamente: + +photos 84 +faces 30 +valid_embeddings 30 +persons 0 +cluster_runs 0 + + +Solo dopo questa verifica esegui il clustering persistente: + +npm run face:cluster -- \ + --user Fabio \ + --scope all \ + --min-cluster-size 3 \ + --min-samples 2 \ + --persist + + +Questa volta la prima run del nuovo database dovrà essere quella appena generata, senza vecchie run precedenti.ù + + +https://prova.patachina.it/faces.html?v=70 diff --git a/helpers/activitySpatialIndex.js b/helpers/activitySpatialIndex.js new file mode 100644 index 0000000..bf12dd8 --- /dev/null +++ b/helpers/activitySpatialIndex.js @@ -0,0 +1,228 @@ +"use strict"; + +const db = require("../db/knex"); + +let RBushClass = null; +let tree = null; +let dirty = true; +let buildPromise = null; +let generation = 0; + +function numberOrNull(value) { + if (value === null || value === undefined || value === "") { + return null; + } + + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +function makeIndexItem(row) { + const minLat = numberOrNull(row.min_latitude); + const maxLat = numberOrNull(row.max_latitude); + const minLon = numberOrNull(row.min_longitude); + const maxLon = numberOrNull(row.max_longitude); + + // Usa il bounding box quando tutti e quattro i limiti sono presenti. + if ( + minLat !== null && + maxLat !== null && + minLon !== null && + maxLon !== null && + minLat >= -90 && + maxLat <= 90 && + minLat <= maxLat && + minLon >= -180 && + maxLon <= 180 && + minLon <= maxLon + ) { + return { + minX: minLon, + minY: minLat, + maxX: maxLon, + maxY: maxLat, + id: String(row.id), + }; + } + + // Fallback per attività prive di bounding box completo. + const lat = numberOrNull(row.center_latitude); + const lon = numberOrNull(row.center_longitude); + + if ( + lat === null || + lon === null || + lat < -90 || + lat > 90 || + lon < -180 || + lon > 180 + ) { + return null; + } + + return { + minX: lon, + minY: lat, + maxX: lon, + maxY: lat, + id: String(row.id), + }; +} + +async function loadRBush() { + if (!RBushClass) { + const module = await import("rbush"); + RBushClass = module.default; + } + + return RBushClass; +} + +async function ensureIndex() { + if (!dirty && tree) { + return tree; + } + + if (buildPromise) { + await buildPromise; + + if (!dirty && tree) { + return tree; + } + } + + const buildGeneration = generation; + + buildPromise = (async () => { + const RBush = await loadRBush(); + + const rows = await db("activities").select( + "id", + "min_latitude", + "max_latitude", + "min_longitude", + "max_longitude", + "center_latitude", + "center_longitude" + ); + + const items = []; + + for (const row of rows) { + const item = makeIndexItem(row); + if (item) items.push(item); + } + + const nextTree = new RBush(); + nextTree.load(items); + + // Non pubblicare un indice costruito mentre era in corso + // un'invalidazione dovuta a una modifica dei dati. + if (buildGeneration === generation) { + tree = nextTree; + dirty = false; + } + })(); + + try { + await buildPromise; + } finally { + buildPromise = null; + } + + if (dirty || !tree) { + return ensureIndex(); + } + + return tree; +} + +function invalidateActivitySpatialIndex() { + generation += 1; + dirty = true; +} + +function searchBoxes(treeInstance, west, east, south, north) { + const boxes = + west <= east + ? [{ minX: west, maxX: east, minY: south, maxY: north }] + : [ + { minX: west, maxX: 180, minY: south, maxY: north }, + { minX: -180, maxX: east, minY: south, maxY: north }, + ]; + + const found = new Map(); + + for (const box of boxes) { + for (const item of treeInstance.search(box)) { + found.set(item.id, item); + } + } + + return [...found.keys()]; +} + +async function searchActivitiesInBounds({ + west, + east, + south, + north, + readableSources, + includeDeleted = false, + limit = 1500, +}) { + const index = await ensureIndex(); + + const ids = searchBoxes(index, west, east, south, north); + + if (!ids.length || !readableSources.length) { + return { items: [], truncated: false }; + } + + const rowsById = new Map(); + const batchSize = 400; + + // Blocchi piccoli per evitare di superare il limite dei parametri SQLite. + for (let i = 0; i < ids.length; i += batchSize) { + const batch = ids.slice(i, i + batchSize); + + let query = db("activities") + .whereIn("id", batch) + .whereIn("user", readableSources); + + if (!includeDeleted) { + query = query.whereNull("deleted_at"); + } + + const rows = await query.select("*"); + + for (const row of rows) { + rowsById.set(String(row.id), row); + } + } + + // Mantiene un ordine deterministico rispetto ai candidati RBush. + const matching = []; + + for (const id of ids) { + const row = rowsById.get(id); + + if (row) { + matching.push(row); + + if (matching.length > limit) { + break; + } + } + } + + return { + items: matching.slice(0, limit), + truncated: matching.length > limit, + }; +} + +module.exports = { + searchActivitiesInBounds, + invalidateActivitySpatialIndex, +}; diff --git a/helpers/hlsManager.js b/helpers/hlsManager.js new file mode 100644 index 0000000..439e362 --- /dev/null +++ b/helpers/hlsManager.js @@ -0,0 +1,1378 @@ +// helpers/hlsManager.js + +const fs = require('fs'); +const path = require('path'); +const { spawn, execFileSync } = require('child_process'); +const fetch = require('node-fetch'); // node-fetch@2 +const config = require('../api_v1/config'); + +// ----------------------------------------------------------------------------- +// Configuration +// ----------------------------------------------------------------------------- + +const FFMPEG_BIN = '/usr/local/bin/ffmpeg'; +const FFPROBE_BIN = '/usr/local/bin/ffprobe'; + +/* + * Temporary private MPP library. + * + * Once MPP 0986d01 is installed as the normal system library, + * remove this constant and the "env: FFMPEG_ENV" options below. + */ +const FFMPEG_ENV = { + ...process.env, + + LD_LIBRARY_PATH: [ + '/usr/local/lib/mpp-0986d01', + process.env.LD_LIBRARY_PATH + ] + .filter(Boolean) + .join(':') +}; + +const HLS_BASE = path.resolve( + process.env.HLS_BASE || + path.join(process.cwd(), 'hls_output') +); + +const jobs = new Map(); + + +// ----------------------------------------------------------------------------- +// FFmpeg / RKMPP detection +// ----------------------------------------------------------------------------- + +function getFfmpegEncoders() { + try { + return execFileSync( + FFMPEG_BIN, + ['-hide_banner', '-encoders'], + { + encoding: 'utf8', + env: FFMPEG_ENV, + stdio: ['ignore', 'pipe', 'pipe'] + } + ); + } catch (err) { + console.error( + `[hlsManager] Cannot execute ${FFMPEG_BIN}: ${err.message}` + ); + + return ''; + } +} + +function hasRkmpp() { + const out = getFfmpegEncoders(); + + const hasH264 = /\bh264_rkmpp\b/.test(out); + const hasHevc = /\bhevc_rkmpp\b/.test(out); + + if (!hasH264 || !hasHevc) { + console.warn( + `[hlsManager] RKMPP not fully available: ` + + `h264_rkmpp=${hasH264}, hevc_rkmpp=${hasHevc}` + ); + + return false; + } + + console.log( + `[hlsManager] RKMPP available: ` + + `h264_rkmpp=${hasH264}, hevc_rkmpp=${hasHevc}` + ); + + return true; +} + + +// ----------------------------------------------------------------------------- +// FFmpeg arguments +// ----------------------------------------------------------------------------- + +/* + * Legge la rotazione dichiarata nel contenitore video. + * + * Restituisce uno dei valori: + * 0, 90, -90, 180 + * + * In caso di errore assume rotazione zero. + */ +function getVideoRotation(inputPath) { + try { + const output = execFileSync( + FFPROBE_BIN, + [ + '-v', + 'error', + + '-select_streams', + 'v:0', + + '-show_entries', + 'stream_tags=rotate:stream_side_data=rotation', + + '-of', + 'json', + + inputPath + ], + { + encoding: 'utf8', + env: FFMPEG_ENV, + stdio: [ + 'ignore', + 'pipe', + 'pipe' + ] + } + ); + + const parsed = JSON.parse(output); + const stream = parsed.streams?.[0] || {}; + + let rotation = null; + + if (Array.isArray(stream.side_data_list)) { + for (const sideData of stream.side_data_list) { + if ( + sideData && + sideData.rotation !== undefined + ) { + rotation = Number(sideData.rotation); + break; + } + } + } + + if ( + rotation === null && + stream.tags && + stream.tags.rotate !== undefined + ) { + rotation = Number(stream.tags.rotate); + } + + if (!Number.isFinite(rotation)) { + return 0; + } + + /* + * Normalizza, ad esempio: + * 270 -> -90 + * -270 -> 90 + */ + rotation = + ((Math.round(rotation) % 360) + 360) % 360; + + if (rotation === 90) { + return 90; + } + + if (rotation === 180) { + return 180; + } + + if (rotation === 270) { + return -90; + } + + return 0; + } catch (err) { + console.warn( + `[hlsManager] Cannot detect rotation for ` + + `${inputPath}: ${err.message}` + ); + + return 0; + } +} + + +function buildFfmpegArgs(inputPath, jobDir, useRkmpp) { + /* + * FFmpeg aggiorna esclusivamente la playlist interna. + * + * Il Chromecast riceve master.m3u8, che viene pubblicata + * separatamente come VOD anticipata da hlsManager. + */ + const internalMaster = + path.join( + jobDir, + 'internal.m3u8' + ); + + const segPattern = + path.join( + jobDir, + 'segment_%05d.ts' + ); + + const rotation = + getVideoRotation(inputPath); + + console.log( + `[hlsManager] video rotation: ${rotation}` + ); + + /* + * Pipeline completamente software: + * + * decode software + * -> rotazione software, se necessaria + * -> scale software + * -> pad software + * -> encode libx264 ultrafast + * + * Il risultato è sempre 1280x720, con aspect ratio + * originale preservato e bande nere quando necessarie. + */ + let rotationFilter = ''; + + if (rotation === -90) { + /* + * Verificato sui file con metadata rotation=-90: + * la trasformazione correttiva è clockwise. + */ + rotationFilter = + 'transpose=clock,'; + } else if (rotation === 90) { + rotationFilter = + 'transpose=cclock,'; + } else if (rotation === 180) { + rotationFilter = + 'hflip,vflip,'; + } + + const videoFilter = + rotationFilter + + 'scale=1280:720:' + + 'force_original_aspect_ratio=decrease:' + + 'force_divisible_by=2,' + + 'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,' + + 'setsar=1,' + + 'format=yuv420p'; + + return [ + '-hide_banner', + + '-loglevel', + 'warning', + + '-y', + + '-probesize', + '5000000', + + '-analyzeduration', + '10000000', + + /* + * Disattiva l'autorotazione automatica. + * La rotazione viene applicata esplicitamente nel filtro. + */ + '-noautorotate', + + /* + * Nessun -hwaccel: il decoder resta software. + */ + '-i', + inputPath, + + '-map', + '0:v:0', + + '-map', + '0:a:0?', + + '-vf', + videoFilter, + + /* + * Encoder completamente software. + */ + '-c:v', + 'libx264', + + '-profile:v', + 'high', + + '-level:v', + '4.1', + + '-preset', + 'ultrafast', + + '-tune', + 'zerolatency', + + '-b:v', + '5000k', + + '-maxrate', + '6000k', + + '-bufsize', + '12000k', + + '-r', + '30', + + '-g', + '90', + + '-keyint_min', + '90', + + '-sc_threshold', + '0', + + /* + * La rotazione è incorporata fisicamente nei frame. + */ + '-metadata:s:v:0', + 'rotate=0', + + '-c:a', + 'aac', + + '-b:a', + '160k', + + '-ac', + '2', + + '-ar', + '48000', + + '-f', + 'hls', + + '-hls_time', + '3', + + '-hls_list_size', + '0', + + /* + * La playlist interna può crescere durante la conversione. + * Non viene esposta direttamente al Chromecast. + */ + '-hls_playlist_type', + 'event', + + '-hls_segment_type', + 'mpegts', + + '-hls_flags', + 'independent_segments', + + '-hls_segment_filename', + segPattern, + + internalMaster + ]; +} + +// ----------------------------------------------------------------------------- +// Callback +// ----------------------------------------------------------------------------- + +async function sendCallback(job, payload) { + if (!job.callbackUrl) { + return; + } + + try { + const resp = await fetch(job.callbackUrl, { + method: 'POST', + + headers: { + 'Content-Type': 'application/json' + }, + + body: JSON.stringify(payload), + + timeout: 10000 + }); + + console.log( + `[job ${job.jobId}] callback ${resp.status}` + ); + } catch (err) { + console.error( + `[job ${job.jobId}] callback failed: ${err.message}` + ); + } +} + + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +/* + * Inserisce nella playlist HLS il punto di partenza preferito. + * + * Questo evita che un client HLS EVENT scelga automaticamente + * un punto vicino al bordo più recente della playlist. + * + * La scrittura avviene su un file temporaneo e il rename finale + * rende l'aggiornamento atomico. + */ +function ensureHlsStartsAtBeginning(masterPath) { + if (!fs.existsSync(masterPath)) { + return false; + } + + const marker = + '#EXT-X-START:TIME-OFFSET=0,PRECISE=YES'; + + const original = + fs.readFileSync( + masterPath, + 'utf8' + ); + + if (original.includes(marker)) { + return true; + } + + const lines = + original.split(/\r?\n/); + + const versionIndex = + lines.findIndex( + line => + line.startsWith( + '#EXT-X-VERSION:' + ) + ); + + const insertIndex = + versionIndex >= 0 + ? versionIndex + 1 + : 1; + + lines.splice( + insertIndex, + 0, + marker + ); + + const temporaryPath = + `${masterPath}.start.tmp`; + + fs.writeFileSync( + temporaryPath, + lines.join('\n') + ); + + fs.renameSync( + temporaryPath, + masterPath + ); + + console.log( + `[hlsManager] Added EXT-X-START to ${masterPath}` + ); + + return true; +} + + +/* + * Quando FFmpeg ha completato la playlist e ha scritto ENDLIST, + * converte il manifest EVENT in un manifest VOD definitivo. + * + * Anche questa modifica viene eseguita atomicamente. + */ +function finalizeHlsAsVod(masterPath) { + if (!fs.existsSync(masterPath)) { + return false; + } + + const original = + fs.readFileSync( + masterPath, + 'utf8' + ); + + if ( + !original.includes( + '#EXT-X-ENDLIST' + ) + ) { + console.warn( + `[hlsManager] Cannot finalize HLS as VOD: ` + + `EXT-X-ENDLIST is missing in ${masterPath}` + ); + + return false; + } + + let finalized = original; + + if ( + finalized.includes( + '#EXT-X-PLAYLIST-TYPE:EVENT' + ) + ) { + finalized = finalized.replace( + '#EXT-X-PLAYLIST-TYPE:EVENT', + '#EXT-X-PLAYLIST-TYPE:VOD' + ); + } + + const temporaryPath = + `${masterPath}.vod.tmp`; + + fs.writeFileSync( + temporaryPath, + finalized + ); + + fs.renameSync( + temporaryPath, + masterPath + ); + + console.log( + `[hlsManager] Finalized HLS as VOD: ${masterPath}` + ); + + return true; +} + + +/* + * Restituisce la durata completa del contenuto in secondi. + * + * La durata viene usata per costruire anticipatamente una playlist + * VOD completa, anche se FFmpeg sta ancora producendo i segmenti. + */ +function getVideoDurationSeconds(inputPath) { + try { + const output = execFileSync( + FFPROBE_BIN, + [ + '-v', + 'error', + + '-show_entries', + 'format=duration', + + '-of', + 'default=noprint_wrappers=1:nokey=1', + + inputPath + ], + { + encoding: 'utf8', + env: FFMPEG_ENV, + stdio: [ + 'ignore', + 'pipe', + 'pipe' + ] + } + ); + + const duration = + Number.parseFloat(output.trim()); + + if ( + !Number.isFinite(duration) || + duration <= 0 + ) { + throw new Error( + `invalid duration: ${output.trim()}` + ); + } + + return duration; + } catch (err) { + console.error( + `[hlsManager] Cannot detect video duration for ` + + `${inputPath}: ${err.message}` + ); + + return null; + } +} + + +/* + * Crea il manifest VOD anticipato esposto al Chromecast. + * + * Al momento della pubblicazione esistono soltanto i primi segmenti, + * ma il manifest dichiara già l'intera timeline. + * + * FFmpeg continua a produrre i segmenti usando internal.m3u8. + */ +function publishAnticipatedVodPlaylist(job) { + if ( + !Number.isFinite(job.durationSeconds) || + job.durationSeconds <= 0 + ) { + console.error( + `[job ${job.jobId}] Cannot create anticipated VOD: ` + + `invalid duration` + ); + + return false; + } + + const segmentDuration = 3; + const totalSegments = Math.ceil( + job.durationSeconds / segmentDuration + ); + + let lastDuration = + job.durationSeconds - + segmentDuration * (totalSegments - 1); + + if ( + !Number.isFinite(lastDuration) || + lastDuration <= 0 + ) { + lastDuration = segmentDuration; + } + + const lines = [ + '#EXTM3U', + '#EXT-X-VERSION:6', + '#EXT-X-TARGETDURATION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-START:TIME-OFFSET=0,PRECISE=YES', + '#EXT-X-PLAYLIST-TYPE:VOD', + '#EXT-X-INDEPENDENT-SEGMENTS' + ]; + + for ( + let index = 0; + index < totalSegments; + index++ + ) { + const duration = + index < totalSegments - 1 + ? segmentDuration + : lastDuration; + + lines.push( + `#EXTINF:${duration.toFixed(6)},` + ); + + lines.push( + `segment_${String(index).padStart(5, '0')}.ts` + ); + } + + lines.push( + '#EXT-X-ENDLIST' + ); + + const temporaryPath = + `${job.masterPath}.anticipated.tmp`; + + fs.writeFileSync( + temporaryPath, + `${lines.join('\n')}\n`, + 'utf8' + ); + + fs.renameSync( + temporaryPath, + job.masterPath + ); + + job.anticipatedPlaylistPublished = true; + job.expectedSegments = totalSegments; + + console.log( + `[job ${job.jobId}] Anticipated VOD published: ` + + `ready=${countSegments(job.outDir)}, ` + + `declared=${totalSegments}, ` + + `duration=${job.durationSeconds.toFixed(3)}` + ); + + return true; +} + + +/* + * Alla fine della transcodifica sostituisce la playlist anticipata + * con quella realmente prodotta da FFmpeg. + * + * La pubblicazione è atomica e conserva lo stesso URL master.m3u8. + */ +function publishFinalVodPlaylist(job) { + if ( + !fs.existsSync(job.internalMasterPath) + ) { + console.error( + `[job ${job.jobId}] Internal playlist missing: ` + + `${job.internalMasterPath}` + ); + + return false; + } + + let content = fs.readFileSync( + job.internalMasterPath, + 'utf8' + ); + + if ( + !content.includes('#EXT-X-ENDLIST') + ) { + console.error( + `[job ${job.jobId}] Internal playlist has no ENDLIST` + ); + + return false; + } + + content = content.replace( + '#EXT-X-PLAYLIST-TYPE:EVENT', + '#EXT-X-PLAYLIST-TYPE:VOD' + ); + + const startMarker = + '#EXT-X-START:TIME-OFFSET=0,PRECISE=YES'; + + if (!content.includes(startMarker)) { + const lines = content.split(/\r?\n/); + + const versionIndex = + lines.findIndex( + line => + line.startsWith( + '#EXT-X-VERSION:' + ) + ); + + lines.splice( + versionIndex >= 0 + ? versionIndex + 1 + : 1, + 0, + startMarker + ); + + content = lines.join('\n'); + } + + if (!content.endsWith('\n')) { + content += '\n'; + } + + const temporaryPath = + `${job.masterPath}.final.tmp`; + + fs.writeFileSync( + temporaryPath, + content, + 'utf8' + ); + + fs.renameSync( + temporaryPath, + job.masterPath + ); + + console.log( + `[job ${job.jobId}] Final VOD playlist published: ` + + `${job.masterPath}` + ); + + return true; +} + + +function countSegments(jobDir) { + if (!fs.existsSync(jobDir)) { + return 0; + } + + try { + return fs.readdirSync(jobDir) + .filter(name => /^segment_\d+\.ts$/.test(name)) + .length; + } catch (err) { + console.error( + `[hlsManager] Cannot read ${jobDir}: ${err.message}` + ); + + return 0; + } +} + + +function buildPublicUrl(job, masterPath) { + const base = + job.publicBaseUrl || + config.BASE_URL || + null; + + const publicRoot = path.resolve( + process.cwd(), + 'public' + ); + + let publicPath; + + if ( + masterPath === publicRoot || + masterPath.startsWith( + `${publicRoot}${path.sep}` + ) + ) { + const rel = path + .relative(publicRoot, masterPath) + .split(path.sep) + .join('/'); + + publicPath = `/${rel}`; + } else { + /* + * Compatibilita con eventuali job legacy + * ancora salvati sotto HLS_BASE. + */ + const rel = path + .relative(HLS_BASE, masterPath) + .split(path.sep) + .join('/'); + + publicPath = `/hls/${rel}`; + } + + if (base) { + return ( + `${base.replace(/\/$/, '')}` + + publicPath + ); + } + + return publicPath; +} + + +// ----------------------------------------------------------------------------- +// Start job +// ----------------------------------------------------------------------------- + +function startJob({ + inputPath, + outDir, + segmentsNeeded = 3, + callbackUrl, + publicBaseUrl = null +}) { + if (!inputPath) { + throw new Error('inputPath is required'); + } + + if (!outDir) { + throw new Error('outDir is required'); + } + +/* + * outDir identifica la directory fisica del video Cast. + */ +const jobDir = path.resolve(outDir); + +/* + * Evita conversioni concorrenti sullo stesso video. + * Se esiste già un job attivo o un output pronto, + * riutilizza il jobId invece di cancellare i segmenti. + */ +for (const existingJob of jobs.values()) { + if ( + path.resolve(existingJob.outDir) !== jobDir || + existingJob.inputPath !== inputPath + ) { + continue; + } + + const masterExists = fs.existsSync( + existingJob.masterPath + ); + + const tsCount = countSegments(jobDir); + + const processRunning = + existingJob.exitCode === null && + existingJob.status !== 'timeout'; + + const outputPlayable = + existingJob.exitCode === 0 && + existingJob.ready && + masterExists && + tsCount >= 1; + + if (processRunning || outputPlayable) { + console.log( + `[job ${existingJob.jobId}] Reuse existing job ` + + `for ${jobDir}` + ); + + return existingJob.jobId; + } +} + +const jobId = Date.now().toString(); + +/* + * Pulisce i vecchi file soltanto quando non c'è + * un job attivo o un output pronto da riutilizzare. + */ +fs.rmSync(jobDir, { + recursive: true, + force: true +}); + +fs.mkdirSync(jobDir, { + recursive: true +}); + + const useRkmpp = false; + + const durationSeconds = + getVideoDurationSeconds( + inputPath + ); + + const ffArgs = buildFfmpegArgs( + inputPath, + jobDir, + useRkmpp + ); + + console.log( + `[job ${jobId}] Starting FFmpeg` + ); + + console.log( + `[job ${jobId}] binary: ${FFMPEG_BIN}` + ); + + console.log( + `[job ${jobId}] RKMPP: ${useRkmpp}` + ); + + console.log( + `[job ${jobId}] input: ${inputPath}` + ); + + console.log( + `[job ${jobId}] output: ${jobDir}` + ); + + console.log( + `[job ${jobId}] args: ${ffArgs.join(' ')}` + ); + + const proc = spawn( + FFMPEG_BIN, + ffArgs, + { + env: FFMPEG_ENV, + + stdio: [ + 'ignore', + 'pipe', + 'pipe' + ] + } + ); + + const job = { + jobId, + + inputPath, + + outDir: jobDir, + + callbackUrl, + + segmentsNeeded: + Math.max(1, Number(segmentsNeeded)), + + proc, + + notified: false, + ready: false, + status: 'processing', + + startTime: Date.now(), + + useRkmpp, + + publicBaseUrl, + + durationSeconds, + + /* + * Playlist pubblica esposta al Chromecast. + */ + masterPath: path.join( + jobDir, + 'master.m3u8' + ), + + /* + * Playlist interna aggiornata direttamente da FFmpeg. + */ + internalMasterPath: path.join( + jobDir, + 'internal.m3u8' + ), + + anticipatedPlaylistPublished: false, + expectedSegments: null, + + exitCode: null, + + error: null, + + poller: null + }; + + jobs.set( + jobId, + job + ); + + // --------------------------------------------------------------------------- + // FFmpeg stdout + // --------------------------------------------------------------------------- + + if (proc.stdout) { + proc.stdout.setEncoding('utf8'); + + proc.stdout.on( + 'data', + data => { + const text = data.toString().trim(); + + if (text) { + console.log( + `[ffmpeg ${jobId}] ${text}` + ); + } + } + ); + } + + // --------------------------------------------------------------------------- + // FFmpeg stderr + // --------------------------------------------------------------------------- + + if (proc.stderr) { + proc.stderr.setEncoding('utf8'); + + proc.stderr.on( + 'data', + data => { + const text = data.toString().trim(); + + if (text) { + console.log( + `[ffmpeg ${jobId}] ${text}` + ); + } + } + ); + } + + // --------------------------------------------------------------------------- + // Spawn error + // --------------------------------------------------------------------------- + + proc.on( + 'error', + err => { + job.error = err; + + console.error( + `[job ${jobId}] FFmpeg process error: ${err.message}` + ); + } + ); + + // --------------------------------------------------------------------------- + // Poll HLS output + // --------------------------------------------------------------------------- + + const pollInterval = 500; + + const timeoutMs = + 5 * 60 * 1000; + + const poller = setInterval( + async () => { + try { + if ( + !job.notified && + fs.existsSync(job.internalMasterPath) + ) { + const tsCount = + countSegments(job.outDir); + +console.log( + `[job ${jobId}] HLS progress: segments=${tsCount}, required=${job.segmentsNeeded}` +); + + if (tsCount >= job.segmentsNeeded) { + + + if ( + + + !job.anticipatedPlaylistPublished && + + + !publishAnticipatedVodPlaylist(job) + + + ) { + + + throw new Error( + + + 'Cannot publish anticipated VOD playlist' + + + ); + + + } + /* + * Prima di comunicare che l'HLS è pronto, + * forza il punto di partenza al primo segmento. + */ + ensureHlsStartsAtBeginning( + job.masterPath + ); + job.ready = true; + job.status = 'ready'; + job.notified = true; + + const publicUrl = + buildPublicUrl( + job, + job.masterPath + ); + + console.log( + `[job ${jobId}] HLS ready: ` + + `${tsCount} segments` + ); + + await sendCallback( + job, + { + jobId: job.jobId, + + master_m3u8: + publicUrl, + + ready_segments: + tsCount, + + status: + 'ready' + } + ); + } + } + + // --------------------------------------------------------------------- + // Timeout + // --------------------------------------------------------------------- + + if ( + !job.notified && + Date.now() - job.startTime > + timeoutMs + ) { + job.notified = true; + job.status = 'timeout'; + + console.error( + `[job ${jobId}] HLS timeout` + ); + + await sendCallback( + job, + { + jobId: job.jobId, + + out_dir: + job.outDir, + + status: + 'timeout' + } + ); + + // Do not kill FFmpeg here automatically. + // The process may still be finishing a large input. + } + } catch (err) { + console.error( + `[job ${jobId}] poller error: ${err.message}` + ); + } + }, + pollInterval + ); + + job.poller = poller; + + // --------------------------------------------------------------------------- + // FFmpeg exit + // --------------------------------------------------------------------------- + + proc.on( + 'exit', + async code => { + clearInterval( + poller + ); + + job.exitCode = code; + + try { + let masterExists = + fs.existsSync( + job.masterPath + ); + + const tsCount = + countSegments( + job.outDir + ); + + /* + * Al termine sostituisce atomicamente la playlist VOD + * anticipata con il manifest reale prodotto da FFmpeg. + */ + if ( + code === 0 && + fs.existsSync(job.internalMasterPath) + ) { + publishFinalVodPlaylist( + job + ); + + /* + * La playlist pubblica può essere stata creata soltanto + * ora, specialmente per i video più corti della soglia + * iniziale di segmentsNeeded. + */ + masterExists = + fs.existsSync( + job.masterPath + ); + } + + console.log( + `[job ${jobId}] FFmpeg exited ` + + `with code ${code}, ` + + `segments=${tsCount}` + ); + + if (!job.notified) { + const publicUrl = + masterExists + ? buildPublicUrl( + job, + job.masterPath + ) + : null; + + +const hasPlayableOutput = + code === 0 && + masterExists && + tsCount >= 1; + +if (hasPlayableOutput) { + job.ready = true; +} + + +job.status = job.ready + ? ( + tsCount >= job.segmentsNeeded + ? 'ready' + : 'finished_short_video' + ) + : ( + code === 0 && masterExists + ? 'finished_insufficient_segments' + : 'error' + ); + + + status = job.status; + job.notified = true; + + await sendCallback( + job, + { + jobId: + job.jobId, + + master_m3u8: + publicUrl, + + ready_segments: + tsCount, + + status, + + returncode: + code + } + ); + } + } catch (err) { + console.error( + `[job ${jobId}] on-exit callback error: ${err.message}` + ); + } finally { + job.finishedAt = Date.now(); + setTimeout(() => { + jobs.delete(jobId); + }, 2 * 60 * 1000).unref(); + } + } + ); + + return jobId; +} + + +// ----------------------------------------------------------------------------- +// Get job +// ----------------------------------------------------------------------------- + +function getJob(jobId) { + return jobs.get( + jobId + ) || null; +} + + +// ----------------------------------------------------------------------------- +// Exports +// ----------------------------------------------------------------------------- + +module.exports = { + startJob, + getJob +}; diff --git a/helpers/hlsManager2.js b/helpers/hlsManager2.js new file mode 100644 index 0000000..64cce0f --- /dev/null +++ b/helpers/hlsManager2.js @@ -0,0 +1,843 @@ +// helpers/hlsManager2.js + +const fs = require('fs'); +const path = require('path'); +const { spawn, execFileSync } = require('child_process'); +const fetch = require('node-fetch'); // node-fetch@2 +const config = require('../api_v1/config'); + +// ----------------------------------------------------------------------------- +// Configuration +// ----------------------------------------------------------------------------- + +const FFMPEG_BIN = '/usr/local/bin/ffmpeg'; + +/* + * Temporary private MPP library. + * + * Once MPP 0986d01 is installed as the normal system library, + * remove this constant and the "env: FFMPEG_ENV" options below. + */ +const FFMPEG_ENV = { + ...process.env, + + LD_LIBRARY_PATH: [ + '/usr/local/lib/mpp-0986d01', + process.env.LD_LIBRARY_PATH + ] + .filter(Boolean) + .join(':') +}; + +const HLS_BASE = path.resolve( + process.env.HLS_BASE || + path.join(process.cwd(), 'hls_output') +); + +const jobs = new Map(); + + +// ----------------------------------------------------------------------------- +// FFmpeg / RKMPP detection +// ----------------------------------------------------------------------------- + +function getFfmpegEncoders() { + try { + return execFileSync( + FFMPEG_BIN, + ['-hide_banner', '-encoders'], + { + encoding: 'utf8', + env: FFMPEG_ENV, + stdio: ['ignore', 'pipe', 'pipe'] + } + ); + } catch (err) { + console.error( + `[hlsManager] Cannot execute ${FFMPEG_BIN}: ${err.message}` + ); + + return ''; + } +} + +function hasRkmpp() { + const out = getFfmpegEncoders(); + + const hasH264 = /\bh264_rkmpp\b/.test(out); + const hasHevc = /\bhevc_rkmpp\b/.test(out); + + if (!hasH264 || !hasHevc) { + console.warn( + `[hlsManager] RKMPP not fully available: ` + + `h264_rkmpp=${hasH264}, hevc_rkmpp=${hasHevc}` + ); + + return false; + } + + console.log( + `[hlsManager] RKMPP available: ` + + `h264_rkmpp=${hasH264}, hevc_rkmpp=${hasHevc}` + ); + + return true; +} + + +// ----------------------------------------------------------------------------- +// FFmpeg arguments +// ----------------------------------------------------------------------------- + +function buildFfmpegArgs(inputPath, jobDir, useRkmpp) { + const master = path.join(jobDir, 'master.m3u8'); + const segPattern = path.join(jobDir, 'segment_%05d.ts'); + + // Filtro video: 720p, proporzioni originali, dimensioni compatibili + // con il codec e formato yuv420p. + const videoFilter = + 'scale=1280:1080:force_original_aspect_ratio=decrease:force_divisible_by=16,' + + 'pad=1280:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p'; + + if (useRkmpp) { + return [ + '-hide_banner', + '-loglevel', + 'warning', + '-y', + + '-probesize', + '5000000', + + '-analyzeduration', + '10000000', + + // Decodifica hardware RKMPP. + '-hwaccel', + 'rkmpp', + + '-i', + inputPath, + + '-map', + '0:v:0', + + '-map', + '0:a:0?', + + // Ridimensionamento a 720p e codifica H.264 hardware. + '-vf', + videoFilter, + + '-c:v', + 'h264_rkmpp', + + '-rc_mode', + 'CBR', + + '-b:v', + '2500k', + + '-maxrate', + '3000k', + + '-bufsize', + '6000k', + + '-r', + '30', + + '-g', + '90', + + '-keyint_min', + '90', + + // Audio AAC stereo. + '-c:a', + 'aac', + + '-b:a', + '160k', + + '-ac', + '2', + + '-ar', + '48000', + + // HLS event: la playlist può crescere durante il transcoding. + '-f', + 'hls', + + '-hls_time', + '3', + + '-hls_list_size', + '0', + + '-hls_playlist_type', + 'event', + + '-hls_segment_type', + 'mpegts', + + '-hls_flags', + 'independent_segments', + + '-hls_segment_filename', + segPattern, + + master + ]; + } + + // Fallback CPU: mantiene il comportamento precedente. + return [ + '-hide_banner', + '-y', + + '-probesize', + '5000000', + + '-analyzeduration', + '10000000', + + '-noautorotate', + + '-i', + inputPath, + + '-map', + '0:v:0', + + '-map', + '0:a:0?', + + '-vf', + videoFilter, + + '-c:v', + 'libx264', + + '-profile:v', + 'high', + + '-level', + '4.1', + + '-preset', + 'veryfast', + + '-tune', + 'zerolatency', + + '-b:v', + '2500k', + + '-maxrate', + '3000k', + + '-bufsize', + '6000k', + + '-r', + '30', + + '-g', + '90', + + '-keyint_min', + '90', + + '-sc_threshold', + '0', + + '-pix_fmt', + 'yuv420p', + + '-c:a', + 'aac', + + '-b:a', + '160k', + + '-ac', + '2', + + '-ar', + '48000', + + '-f', + 'hls', + + '-hls_time', + '3', + + '-hls_list_size', + '0', + + '-hls_playlist_type', + 'event', + + '-hls_segment_type', + 'mpegts', + + '-hls_flags', + 'independent_segments', + + '-hls_segment_filename', + segPattern, + + master + ]; +} + +// ----------------------------------------------------------------------------- +// Callback +// ----------------------------------------------------------------------------- + +async function sendCallback(job, payload) { + if (!job.callbackUrl) { + return; + } + + try { + const resp = await fetch(job.callbackUrl, { + method: 'POST', + + headers: { + 'Content-Type': 'application/json' + }, + + body: JSON.stringify(payload), + + timeout: 10000 + }); + + console.log( + `[job ${job.jobId}] callback ${resp.status}` + ); + } catch (err) { + console.error( + `[job ${job.jobId}] callback failed: ${err.message}` + ); + } +} + + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +function countSegments(jobDir) { + if (!fs.existsSync(jobDir)) { + return 0; + } + + try { + return fs.readdirSync(jobDir) + .filter(name => /^segment_\d+\.ts$/.test(name)) + .length; + } catch (err) { + console.error( + `[hlsManager] Cannot read ${jobDir}: ${err.message}` + ); + + return 0; + } +} + + +function buildPublicUrl(job, masterPath) { + const base = + job.publicBaseUrl || + config.BASE_URL || + null; + + const publicRoot = path.resolve( + process.cwd(), + 'public' + ); + + let publicPath; + + if ( + masterPath === publicRoot || + masterPath.startsWith( + `${publicRoot}${path.sep}` + ) + ) { + const rel = path + .relative(publicRoot, masterPath) + .split(path.sep) + .join('/'); + + publicPath = `/${rel}`; + } else { + /* + * Compatibilita con eventuali job legacy + * ancora salvati sotto HLS_BASE. + */ + const rel = path + .relative(HLS_BASE, masterPath) + .split(path.sep) + .join('/'); + + publicPath = `/hls/${rel}`; + } + + if (base) { + return ( + `${base.replace(/\/$/, '')}` + + publicPath + ); + } + + return publicPath; +} + + +// ----------------------------------------------------------------------------- +// Start job +// ----------------------------------------------------------------------------- + +function startJob({ + inputPath, + outDir, + segmentsNeeded = 3, + callbackUrl, + publicBaseUrl = null +}) { + if (!inputPath) { + throw new Error('inputPath is required'); + } + + if (!outDir) { + throw new Error('outDir is required'); + } + +/* + * outDir identifica la directory fisica del video Cast. + */ +const jobDir = path.resolve(outDir); + +/* + * Evita conversioni concorrenti sullo stesso video. + * Se esiste già un job attivo o un output pronto, + * riutilizza il jobId invece di cancellare i segmenti. + */ +for (const existingJob of jobs.values()) { + if ( + path.resolve(existingJob.outDir) !== jobDir || + existingJob.inputPath !== inputPath + ) { + continue; + } + + const masterExists = fs.existsSync( + existingJob.masterPath + ); + + const tsCount = countSegments(jobDir); + + const processRunning = + existingJob.exitCode === null && + existingJob.status !== 'timeout'; + + const outputPlayable = + existingJob.exitCode === 0 && + existingJob.ready && + masterExists && + tsCount >= 1; + + if (processRunning || outputPlayable) { + console.log( + `[job ${existingJob.jobId}] Reuse existing job ` + + `for ${jobDir}` + ); + + return existingJob.jobId; + } +} + +const jobId = Date.now().toString(); + +/* + * Pulisce i vecchi file soltanto quando non c'è + * un job attivo o un output pronto da riutilizzare. + */ +fs.rmSync(jobDir, { + recursive: true, + force: true +}); + +fs.mkdirSync(jobDir, { + recursive: true +}); + + const useRkmpp = true; + + const ffArgs = buildFfmpegArgs( + inputPath, + jobDir, + useRkmpp + ); + + console.log( + `[job ${jobId}] Starting FFmpeg` + ); + + console.log( + `[job ${jobId}] binary: ${FFMPEG_BIN}` + ); + + console.log( + `[job ${jobId}] RKMPP: ${useRkmpp}` + ); + + console.log( + `[job ${jobId}] input: ${inputPath}` + ); + + console.log( + `[job ${jobId}] output: ${jobDir}` + ); + + console.log( + `[job ${jobId}] args: ${ffArgs.join(' ')}` + ); + + const proc = spawn( + FFMPEG_BIN, + ffArgs, + { + env: FFMPEG_ENV, + + stdio: [ + 'ignore', + 'pipe', + 'pipe' + ] + } + ); + + const job = { + jobId, + + inputPath, + + outDir: jobDir, + + callbackUrl, + + segmentsNeeded: + Math.max(1, Number(segmentsNeeded)), + + proc, + + notified: false, + ready: false, + status: 'processing', + + startTime: Date.now(), + + useRkmpp, + + publicBaseUrl, + + masterPath: path.join( + jobDir, + 'master.m3u8' + ), + + exitCode: null, + + error: null, + + poller: null + }; + + jobs.set( + jobId, + job + ); + + // --------------------------------------------------------------------------- + // FFmpeg stdout + // --------------------------------------------------------------------------- + + if (proc.stdout) { + proc.stdout.setEncoding('utf8'); + + proc.stdout.on( + 'data', + data => { + const text = data.toString().trim(); + + if (text) { + console.log( + `[ffmpeg ${jobId}] ${text}` + ); + } + } + ); + } + + // --------------------------------------------------------------------------- + // FFmpeg stderr + // --------------------------------------------------------------------------- + + if (proc.stderr) { + proc.stderr.setEncoding('utf8'); + + proc.stderr.on( + 'data', + data => { + const text = data.toString().trim(); + + if (text) { + console.log( + `[ffmpeg ${jobId}] ${text}` + ); + } + } + ); + } + + // --------------------------------------------------------------------------- + // Spawn error + // --------------------------------------------------------------------------- + + proc.on( + 'error', + err => { + job.error = err; + + console.error( + `[job ${jobId}] FFmpeg process error: ${err.message}` + ); + } + ); + + // --------------------------------------------------------------------------- + // Poll HLS output + // --------------------------------------------------------------------------- + + const pollInterval = 500; + + const timeoutMs = + 5 * 60 * 1000; + + const poller = setInterval( + async () => { + try { + if ( + !job.notified && + fs.existsSync(job.masterPath) + ) { + const tsCount = + countSegments(job.outDir); + +console.log( + `[job ${jobId}] HLS progress: segments=${tsCount}, required=${job.segmentsNeeded}` +); + + if (tsCount >= job.segmentsNeeded) { + job.ready = true; + job.status = 'ready'; + job.notified = true; + + const publicUrl = + buildPublicUrl( + job, + job.masterPath + ); + + console.log( + `[job ${jobId}] HLS ready: ` + + `${tsCount} segments` + ); + + await sendCallback( + job, + { + jobId: job.jobId, + + master_m3u8: + publicUrl, + + ready_segments: + tsCount, + + status: + 'ready' + } + ); + } + } + + // --------------------------------------------------------------------- + // Timeout + // --------------------------------------------------------------------- + + if ( + !job.notified && + Date.now() - job.startTime > + timeoutMs + ) { + job.notified = true; + job.status = 'timeout'; + + console.error( + `[job ${jobId}] HLS timeout` + ); + + await sendCallback( + job, + { + jobId: job.jobId, + + out_dir: + job.outDir, + + status: + 'timeout' + } + ); + + // Do not kill FFmpeg here automatically. + // The process may still be finishing a large input. + } + } catch (err) { + console.error( + `[job ${jobId}] poller error: ${err.message}` + ); + } + }, + pollInterval + ); + + job.poller = poller; + + // --------------------------------------------------------------------------- + // FFmpeg exit + // --------------------------------------------------------------------------- + + proc.on( + 'exit', + async code => { + clearInterval( + poller + ); + + job.exitCode = code; + + try { + const masterExists = + fs.existsSync( + job.masterPath + ); + + const tsCount = + countSegments( + job.outDir + ); + + console.log( + `[job ${jobId}] FFmpeg exited ` + + `with code ${code}, ` + + `segments=${tsCount}` + ); + + if (!job.notified) { + const publicUrl = + masterExists + ? buildPublicUrl( + job, + job.masterPath + ) + : null; + + +const hasPlayableOutput = + code === 0 && + masterExists && + tsCount >= 1; + +if (hasPlayableOutput) { + job.ready = true; +} + + +job.status = job.ready + ? ( + tsCount >= job.segmentsNeeded + ? 'ready' + : 'finished_short_video' + ) + : ( + code === 0 && masterExists + ? 'finished_insufficient_segments' + : 'error' + ); + + + status = job.status; + job.notified = true; + + await sendCallback( + job, + { + jobId: + job.jobId, + + master_m3u8: + publicUrl, + + ready_segments: + tsCount, + + status, + + returncode: + code + } + ); + } + } catch (err) { + console.error( + `[job ${jobId}] on-exit callback error: ${err.message}` + ); + } finally { + job.finishedAt = Date.now(); + setTimeout(() => { + jobs.delete(jobId); + }, 2 * 60 * 1000).unref(); + } + } + ); + + return jobId; +} + + +// ----------------------------------------------------------------------------- +// Get job +// ----------------------------------------------------------------------------- + +function getJob(jobId) { + return jobs.get( + jobId + ) || null; +} + + +// ----------------------------------------------------------------------------- +// Exports +// ----------------------------------------------------------------------------- + +module.exports = { + startJob, + getJob +}; diff --git a/helpers/normalizeActivity.js b/helpers/normalizeActivity.js new file mode 100644 index 0000000..ee6423c --- /dev/null +++ b/helpers/normalizeActivity.js @@ -0,0 +1,158 @@ +// helpers/normalizeActivity.js + +function safeJsonParse(value, fallback) { + try { + return value ? JSON.parse(value) : fallback; + } catch { + return fallback; + } +} + +function normalizeActivity(a) { + // ============================ + // GPS compatibile con /photos + // ============================ + const gps = (a.center_latitude != null && a.center_longitude != null) + ? { + lat: a.center_latitude, + lng: a.center_longitude, + alt: a.min_elevation_meters ?? null + } + : null; + + // ============================ + // JSON fields + // ============================ + const location = safeJsonParse(a.location, null); + const tags = safeJsonParse(a.tags, []); + const diagnostics = safeJsonParse(a.diagnostics, null); + + // Preview percorso per thumbnails attività + const track_preview = safeJsonParse(a.track_preview, []); + + // ============================ + // Path stile /photos + // ============================ + const path = `/activities/${a.user}/original/${a.cartella}/${a.file_name}`; + + // ============================ + // MIME type + // ============================ + const mime_type = a.format || "application/octet-stream"; + + // ============================ + // mtimeMs compatibile progressiveSync + // ============================ + const mtimeMs = + a.last_modified_millis ?? + a.date_modified_millis ?? + a.date_added_millis ?? + Date.now(); + + return { + // ============================ + // Campi base come /photos + // ============================ + id: a.id, + user: a.user, + cartella: a.cartella, + name: a.file_name, + path, + mime_type, + size_bytes: a.size_bytes, + mtimeMs, + + taken_at: a.start_time_millis + ? new Date(Number(a.start_time_millis)).toISOString() + : null, + + data: a.start_time_millis + ? new Date(Number(a.start_time_millis)).toISOString() + : null, + + location, + gps, + + // ============================ + // Campi speciali delle activity + // ============================ + file_name: a.file_name, + file_extension: a.file_extension, + format: a.format, + display_name: a.display_name, + route_name: a.route_name, + title: a.title, + description: a.description, + notes: a.notes, + sport: a.sport, + activity_type: a.activity_type, + device_manufacturer: a.device_manufacturer, + device_model: a.device_model, + source_app: a.source_app, + + start_time_millis: a.start_time_millis, + end_time_millis: a.end_time_millis, + duration_millis: a.duration_millis, + + distance_meters: a.distance_meters, + elevation_gain_meters: a.elevation_gain_meters, + elevation_loss_meters: a.elevation_loss_meters, + + min_elevation_meters: a.min_elevation_meters, + max_elevation_meters: a.max_elevation_meters, + + file_distance_meters: a.file_distance_meters, + file_elevation_gain_meters: a.file_elevation_gain_meters, + + computed_distance_meters: a.computed_distance_meters, + computed_elevation_gain_meters: a.computed_elevation_gain_meters, + + average_speed_mps: a.average_speed_mps, + max_speed_mps: a.max_speed_mps, + average_pace_sec_per_km: a.average_pace_sec_per_km, + + average_heart_rate: a.average_heart_rate, + max_heart_rate: a.max_heart_rate, + average_cadence: a.average_cadence, + max_cadence: a.max_cadence, + + calories: a.calories, + power_avg: a.power_avg, + power_max: a.power_max, + + point_count: a.point_count, + segment_count: a.segment_count, + lap_count: a.lap_count, + + min_latitude: a.min_latitude, + max_latitude: a.max_latitude, + min_longitude: a.min_longitude, + max_longitude: a.max_longitude, + center_latitude: a.center_latitude, + center_longitude: a.center_longitude, + + last_modified_millis: a.last_modified_millis, + date_added_millis: a.date_added_millis, + date_modified_millis: a.date_modified_millis, + last_scan_millis: a.last_scan_millis, + + is_favorite: !!a.is_favorite, + is_hidden: !!a.is_hidden, + rating: a.rating, + + tags, + + // Preview percorso per thumbnails FIT/GPX/TCX + track_preview, + + scan_status: a.scan_status, + scan_error: a.scan_error, + + // Importante per soft delete in gallery + deleted_at: a.deleted_at || null, + + diagnostics + }; +} + +module.exports = normalizeActivity; diff --git a/middleware/faceReviewUser.js b/middleware/faceReviewUser.js new file mode 100644 index 0000000..43e2d9c --- /dev/null +++ b/middleware/faceReviewUser.js @@ -0,0 +1,55 @@ +function createHttpError(status, message) { + const error = new Error(message); + error.status = status; + return error; +} + +function getAuthenticatedUser(req) { + const user = String( + req.user?.name || '', + ).trim(); + + if (!user) { + throw createHttpError( + 401, + 'Utente autenticato non disponibile', + ); + } + + return user; +} + +function isFaceReviewAdmin(req) { + return getAuthenticatedUser(req) === 'Admin'; +} + +function resolveFaceReviewUser(req) { + const authenticatedUser = + getAuthenticatedUser(req); + + const requestedUser = String( + req.query?.user + ?? req.body?.user + ?? '', + ).trim(); + + if (!requestedUser) { + return authenticatedUser; + } + + if (!isFaceReviewAdmin(req)) { + throw createHttpError( + 403, + 'Selezione utente non autorizzata', + ); + } + + return requestedUser; +} + +module.exports = { + createHttpError, + getAuthenticatedUser, + isFaceReviewAdmin, + resolveFaceReviewUser, +}; diff --git a/middleware/hls.js b/middleware/hls.js new file mode 100644 index 0000000..0f5115e --- /dev/null +++ b/middleware/hls.js @@ -0,0 +1,18 @@ +const express = require('express'); +const path = require('path'); + +module.exports = express.static( + path.join(__dirname, '..', 'public/hls'), + { + setHeaders: (res, filePath) => { + if (filePath.endsWith('.m3u8')) { + res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); + } + if (filePath.endsWith('.ts')) { + res.setHeader('Content-Type', 'video/mp2t'); + } + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + } + } +); diff --git a/middleware/jwt.js b/middleware/jwt.js new file mode 100644 index 0000000..56eee58 --- /dev/null +++ b/middleware/jwt.js @@ -0,0 +1,22 @@ +const { verifyToken, isRevoked } = require('../utils/jwtHelpers'); + +const jwtMiddleware = (req, res, next) => { + const auth = req.headers.authorization || ''; + const [scheme, token] = auth.split(' '); + + if (scheme !== 'Bearer' || !token) { + return res.status(401).json({ status: 401, message: 'Bad authorization header' }); + } + if (isRevoked(token)) { + return res.status(401).json({ status: 401, message: 'Token revoked' }); + } + + try { + req.user = verifyToken(token); + next(); + } catch { + return res.status(401).json({ status: 401, message: 'Error: access_token is not valid' }); + } +}; + +module.exports = { jwtMiddleware }; diff --git a/middleware/static.js b/middleware/static.js new file mode 100644 index 0000000..f4bd3db --- /dev/null +++ b/middleware/static.js @@ -0,0 +1,28 @@ +const express = require('express'); +const path = require('path'); + +const PUBLIC_ROOT = path.join(__dirname, '..', 'public'); + +module.exports = express.static(PUBLIC_ROOT, { + etag: true, + lastModified: true, + cacheControl: true, + + setHeaders(res, filePath) { + const normalizedPath = filePath + .replaceAll('\\', '/') + .toLowerCase(); + + const isThumbnail = + normalizedPath.includes('/photos/') && + normalizedPath.includes('/thumbs/') && + /\.(jpg|jpeg|png|webp|gif)$/i.test(normalizedPath); + + if (isThumbnail) { + res.setHeader( + 'Cache-Control', + 'public, max-age=86400' + ); + } + } +}); diff --git a/middleware/waitForCastSegment.js b/middleware/waitForCastSegment.js new file mode 100644 index 0000000..c24ff1f --- /dev/null +++ b/middleware/waitForCastSegment.js @@ -0,0 +1,298 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const SEGMENT_PATTERN = + /^segment_[0-9]{5}\.ts$/; + +const DEFAULT_TIMEOUT_MS = + Number( + process.env.CAST_SEGMENT_WAIT_TIMEOUT_MS || + 300000 + ); + +const DEFAULT_POLL_MS = + Number( + process.env.CAST_SEGMENT_WAIT_POLL_MS || + 100 + ); + +const DEFAULT_STABILITY_MS = + Number( + process.env.CAST_SEGMENT_STABILITY_MS || + 50 + ); + + +function sleep(ms) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} + + +async function getStableFileSize( + filePath, + stabilityMs +) { + try { + const first = + await fs.promises.stat(filePath); + + if ( + !first.isFile() || + first.size <= 0 + ) { + return null; + } + + await sleep(stabilityMs); + + const second = + await fs.promises.stat(filePath); + + if ( + !second.isFile() || + second.size <= 0 || + second.size !== first.size + ) { + return null; + } + + return second.size; + } catch { + return null; + } +} + + +function isInsideRoot(root, candidate) { + const relative = path.relative( + root, + candidate + ); + + return ( + relative !== '' && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + + +function requestIsClosed(req, res) { + return ( + req.destroyed || + res.destroyed || + res.writableEnded + ); +} + + +function createWaitForCastSegment({ + castRoot, + timeoutMs = DEFAULT_TIMEOUT_MS, + pollMs = DEFAULT_POLL_MS, + stabilityMs = DEFAULT_STABILITY_MS +}) { + const resolvedRoot = + path.resolve(castRoot); + + return async function waitForCastSegment( + req, + res, + next + ) { + try { + if ( + req.method !== 'GET' && + req.method !== 'HEAD' + ) { + next(); + return; + } + + let pathname; + + try { + pathname = decodeURIComponent( + req.path || + req.url.split('?')[0] + ); + } catch { + res + .status(400) + .end('Invalid URL'); + + return; + } + + const filename = + path.basename(pathname); + + if (!SEGMENT_PATTERN.test(filename)) { + next(); + return; + } + + /* + * Il middleware è montato su /cast. + * req.path è quindi relativo a public/cast. + */ + const relativePath = + pathname.replace(/^\/+/, ''); + + const filePath = + path.resolve( + resolvedRoot, + relativePath + ); + + if (!isInsideRoot( + resolvedRoot, + filePath + )) { + res + .status(403) + .end('Forbidden'); + + return; + } + + /* + * Se il segmento esiste già ed è stabile, + * prosegue immediatamente verso express.static. + */ + const initialSize = + await getStableFileSize( + filePath, + stabilityMs + ); + + if (initialSize !== null) { + next(); + return; + } + + const startedAt = Date.now(); + const deadline = + startedAt + timeoutMs; + + let lastProgressLogAt = 0; + + console.log( + `[castSegmentWait] WAIT ` + + `${relativePath} ` + + `timeoutMs=${timeoutMs}` + ); + + while (Date.now() < deadline) { + if (requestIsClosed(req, res)) { + console.log( + `[castSegmentWait] CLIENT_CLOSED ` + + `${relativePath} ` + + `waitedMs=${Date.now() - startedAt}` + ); + + return; + } + + const size = + await getStableFileSize( + filePath, + stabilityMs + ); + + if (size !== null) { + console.log( + `[castSegmentWait] READY ` + + `${relativePath} ` + + `waitedMs=${Date.now() - startedAt} ` + + `size=${size}` + ); + + /* + * Il file ora esiste ed è stabile. + * express.static lo invierà al client. + */ + next(); + return; + } + + const now = Date.now(); + + if ( + now - lastProgressLogAt >= 5000 + ) { + console.log( + `[castSegmentWait] STILL_WAITING ` + + `${relativePath} ` + + `elapsedMs=${now - startedAt}` + ); + + lastProgressLogAt = now; + } + + await sleep(pollMs); + } + + if (requestIsClosed(req, res)) { + return; + } + + console.warn( + `[castSegmentWait] TIMEOUT ` + + `${relativePath} ` + + `waitedMs=${Date.now() - startedAt}` + ); + + res + .status(503) + .set({ + 'Cache-Control': + 'no-store', + 'Retry-After': + '1' + }) + .end( + 'Segment not ready' + ); + } catch (err) { + /* + * Chromecast può chiudere richieste speculative. + * Non è un errore di transcodifica. + */ + if ( + err && + ( + err.code === 'EPIPE' || + err.code === 'ECONNRESET' + ) + ) { + console.log( + `[castSegmentWait] CLIENT_DISCONNECTED ` + + `${req.originalUrl || req.url}` + ); + + return; + } + + console.error( + `[castSegmentWait] ERROR ` + + `${req.originalUrl || req.url}: ` + + `${err.stack || err.message}` + ); + + if (!requestIsClosed(req, res)) { + next(err); + } + } + }; +} + + +module.exports = { + createWaitForCastSegment +}; diff --git a/models/SCRFD_500M_KPS_640.rknn b/models/SCRFD_500M_KPS_640.rknn new file mode 100644 index 0000000..ae8757c Binary files /dev/null and b/models/SCRFD_500M_KPS_640.rknn differ diff --git a/models/face_recognition_sface_2021dec.rknn b/models/face_recognition_sface_2021dec.rknn new file mode 100644 index 0000000..ebc0fe0 Binary files /dev/null and b/models/face_recognition_sface_2021dec.rknn differ diff --git a/native/face-knn/SHA256SUMS b/native/face-knn/SHA256SUMS new file mode 100644 index 0000000..d6a6a2b --- /dev/null +++ b/native/face-knn/SHA256SUMS @@ -0,0 +1,4 @@ +e74bd672eebaa0f10028e25c0f7051e981cd16c2eeb9c529f513a6c412c11033 bin/face_knn +6c4f502438aad3dd1600a00cb7988e0e35e56b2e8e4620d44ffcb0f3610a7226 kernels/knn_float4_topk.cl +2da5a7f17599a3d442a10c5212ff124eb894942378f3e531b92fd5cf172fc035 run-face-knn.sh +80b11a8f3b6c3c2d31a1eed1ad348da0c86caba9772dfb87ac1dd0e548ae9ef8 config.json diff --git a/native/face-knn/config.json b/native/face-knn/config.json new file mode 100644 index 0000000..4ced0a0 --- /dev/null +++ b/native/face-knn/config.json @@ -0,0 +1,14 @@ +{ + "enabled": true, + "mode": "shadow", + "backend": "opencl", + "fallbackBackend": "cpu", + "dimension": 128, + "topK": 5, + "recognitionThreshold": 0.5, + "scoreTolerance": 0.00001, + "autoBackendMinimumComparisons": 100000, + "launcher": "native/face-knn/run-face-knn.sh", + "indexDirectory": "api_v1/face-recognition-index", + "runtimeDirectory": "var/face-knn" +} diff --git a/native/face-knn/docs/README.md b/native/face-knn/docs/README.md new file mode 100644 index 0000000..82b7ddf --- /dev/null +++ b/native/face-knn/docs/README.md @@ -0,0 +1,481 @@ +# Face Recognition RK3588 OpenCL + +Motore C++ per rilevamento facciale, generazione di embedding e riconoscimento kNN accelerato su Rockchip RK3588. + +Il progetto usa: + +- NPU tramite RKNN per SCRFD e SFace; +- GPU Mali-G610 tramite OpenCL per kNN; +- CPU C++ come riferimento numerico e fallback. + +## Funzionalità + +- rilevamento volti con SCRFD; +- confidence, bounding box e cinque landmark; +- non-maximum suppression; +- allineamento volto 112 x 112; +- embedding SFace float32 a 128 dimensioni; +- normalizzazione L2; +- cosine similarity tramite prodotto scalare; +- kNN CPU e OpenCL; +- conteggi dinamici, TOPK configurabile e chunking; +- leave-one-out tramite self-map; +- JSON su stdout e diagnostica su stderr. + +## Architettura + +```text +Immagine + | + v +SCRFD tramite RKNN / NPU + | + v +Bounding box e landmark + | + v +Allineamento volto 112 x 112 + | + v +SFace tramite RKNN / NPU + | + v +Embedding float32[128] normalizzato L2 + | + +-----------------------------+ + | | + v v +kNN CPU C++ kNN OpenCL / Mali-G610 + | | + +-------------+---------------+ + | + v + TOPK prototype + | + v + Leave-one-out opzionale + | + v + Soglia di riconoscimento +``` + +## Formato degli embedding + +```text +Dimensione: 128 +Tipo: float32 +Dimensione binaria: 512 byte +Ordine byte: little-endian +Normalizzazione: L2 +Norma attesa: circa 1.0 +``` + +Poiché gli embedding sono normalizzati: + +```text +cosine_similarity(a, b) = dot(a, b) +``` + +## Componenti + +### Face engine RKNN + +```text +include/face_config.h +include/face_engine.h +include/rknn_api.h +src/face_engine.cc +src/face_recognition.cc +src/face_scan.cc +``` + +### Motore kNN + +```text +include/knn_opencl.h +src/knn_opencl.cc +src/knn_opencl_backend.cc +src/face_knn.cc +kernels/knn_float4_topk.cl +kernels/knn_reference.cl +``` + +Il kernel produttivo viene compilato a runtime, ad esempio con: + +```text +-cl-fast-relaxed-math +-D DIM=128 +-D TOPK=5 +-D QUERY_COUNT=21 +-D LANES_PER_QUERY=8 +``` + +## Requisiti + +- Linux AArch64; +- Rockchip RK3588 o piattaforma compatibile; +- compilatore C++17 e GNU Make; +- runtime RKNN; +- OpenCL ICD loader e driver Mali; +- modelli SCRFD e SFace in formato RKNN. + +Su Debian: + +```bash +sudo apt-get install \ + build-essential \ + opencl-headers \ + ocl-icd-opencl-dev +``` + +Verifica OpenCL: + +```bash +clinfo 2>/dev/null \ + | grep -E 'Platform Name|Device Name|Device Type|Device Version' +``` + +## File locali richiesti + +Non sono versionati: + +```text +models/SCRFD_500M_KPS_640.rknn +models/face_recognition_sface_2021dec.rknn +lib/librknnrt.so +``` + +## Compilazione + +Dalla root del repository: + +```bash +make clean +make -j"$(nproc)" +``` + +Binari previsti: + +```text +bin/face_recognition +bin/face_scan +bin/face_knn +``` + +Verifica del binario OpenCL: + +```bash +file bin/face_knn +ldd bin/face_knn | grep -Ei 'opencl|stdc|not found' +``` + +## Utilizzo dei binari + +### face_recognition + +Programma dimostrativo con output destinato alla lettura umana. + +Analisi di una singola fotografia: + +```bash +./bin/face_recognition test/test.jpg +``` + +Confronto tra due fotografie: + +```bash +./bin/face_recognition \ + test/test.jpg \ + test/test3f.jpg +``` + +Usarlo per verificare modelli e runtime RKNN, detection, landmark, embedding e similarità. Non è il binario consigliato per integrazioni applicative. + +### face_scan + +Elabora una fotografia e produce JSON con immagine, volti, confidence, bounding box, landmark ed embedding. + +```bash +./bin/face_scan test/test3f.jpg +``` + +Separazione di risultato e diagnostica: + +```bash +./bin/face_scan test/test3f.jpg \ + > /tmp/face-scan.json \ + 2> /tmp/face-scan.stderr +``` + +Validazione: + +```bash +python3 -m json.tool /tmp/face-scan.json > /dev/null \ + && echo "OK: JSON valido" +``` + +Exit code: + +```text +0 scansione completata +2 argomenti non validi +3 inizializzazione FaceEngine fallita +4 caricamento o elaborazione immagine fallita +5 risultato facciale o embedding non valido +``` + +Usarlo per integrare il face engine con Node.js, Python o altri linguaggi e per costruire query destinate a `face_knn`. + +### face_knn + +Confronta embedding query con un database di prototype. Backend disponibili: + +```text +cpu +opencl +``` + +Il programma va normalmente eseguito dalla root del repository, perché il kernel viene caricato da: + +```text +kernels/knn_float4_topk.cl +``` + +Help: + +```bash +./bin/face_knn --help +``` + +Backend CPU: + +```bash +./bin/face_knn \ + --queries queries.bin \ + --database prototypes.bin \ + --query-count 21 \ + --database-count 5 \ + --dimension 128 \ + --top-k 5 \ + --threshold 0.50 \ + --backend cpu +``` + +Backend OpenCL: + +```bash +./bin/face_knn \ + --queries queries.bin \ + --database prototypes.bin \ + --query-count 21 \ + --database-count 5 \ + --dimension 128 \ + --top-k 5 \ + --threshold 0.50 \ + --backend opencl +``` + +Argomenti: + +```text +--queries file binario degli embedding query +--database file binario degli embedding prototype +--query-count numero di query +--database-count numero di prototype +--dimension componenti per embedding, 128 per SFace +--top-k numero massimo di vicini +--threshold soglia minima di riconoscimento +--self-map esclusioni leave-one-out opzionali +--backend cpu oppure opencl +``` + +Il TOPK effettivo è: + +```text +effective_top_k = min(top_k, database_count) +``` + +#### Leave-one-out + +Quando una query è anche un prototype, il confronto con sé stessa va escluso. La self-map contiene una riga per query: + +```text +-1 +1 +3 +0 +4 +-1 +``` + +`-1` indica nessuna esclusione; un valore da zero in su indica l'indice del prototype da escludere. + +```bash +./bin/face_knn \ + --queries queries.bin \ + --database prototypes.bin \ + --query-count 21 \ + --database-count 5 \ + --dimension 128 \ + --top-k 5 \ + --threshold 0.50 \ + --self-map self-map.txt \ + --backend opencl +``` + +#### Output e diagnostica + +```bash +./bin/face_knn \ + --queries queries.bin \ + --database prototypes.bin \ + --query-count 21 \ + --database-count 5 \ + --dimension 128 \ + --top-k 5 \ + --threshold 0.50 \ + --self-map self-map.txt \ + --backend opencl \ + > /tmp/face-knn.json \ + 2> /tmp/face-knn.stderr +``` + +```text +stdout → JSON valido +stderr → diagnostica OpenCL e driver Mali +``` + +Validazione: + +```bash +python3 -m json.tool /tmp/face-knn.json > /dev/null \ + && echo "OK: JSON valido" +``` + +Campi principali del JSON: + +```text +backend, device_name, query_count, database_count, dimension, top_k, +threshold, leave_one_out, elapsed_ms, results, recognized_count, +below_threshold_count, coverage_percent +``` + +Per ogni query: + +```text +query_index, excluded_index, recognized, best_index, best_score, neighbors +``` + +### test_knn_cpu + +Sorgente: + +```text +test/knn/test_knn_cpu.cc +``` + +Esecuzione, se compilato: + +```bash +./bin/test_knn_cpu +``` + +Output atteso: + +```text +OK: backend CPU dinamico +query_count=2 +database_count=3 +top_k=2 +``` + +## Scelta del backend + +Usare CPU per database piccoli, riferimento numerico o fallback. Usare OpenCL per database grandi o molte query. Con cinque prototype l'overhead OpenCL può superare il costo del calcolo; con 350000 prototype e 256 query è stato misurato uno speedup di circa 7 volte. + +## Flusso completo + +```text +face_scan +→ estrae gli embedding +→ applicazione seleziona i prototype +→ face_knn calcola TOPK +→ applicazione associa gli indici alle identità +→ applicazione applica soglia e margine +→ applicazione salva il risultato +``` + +## Prestazioni sintetiche validate + +```text +Database: 350000 +Query: 256 +Dimensione: 128 +TOPK: 32 +Chunk: 50000 +GPU: Mali-G610 r0p0 +CPU: 9663.226 ms +OpenCL: 1373.797 ms +Speedup: 7.034x +TOPK identico: 8192 / 8192 +``` + +Variante float4 min-replace: + +```text +Kernel: 1244.576 ms +Throughput: 71.992 M vector/s +Dot rate: 18.430 GFLOP/s +Traffico effettivo: 36.860 GB/s +``` + +## Validazione reale + +```text +Query: 21 +Prototype: 5 +Dimensione: 128 +Soglia: 0.50 +Leave-one-out: attivo +Riconosciuti: 20 / 21 +Copertura: 95.2% +Caso sotto soglia: IMG_0183.JPG +Score: 0.466428 +Miglior prototype: IMG_0143.JPG +``` + +Confronti: + +```text +Python vs C++ CPU: 21 / 21 query coerenti +CPU vs OpenCL dopo leave-one-out: 100 / 100 posizioni +CPU vs OpenCL TOPK grezzo: 105 / 105 posizioni +Errore massimo CPU/OpenCL: 3.58e-7 +``` + +Dettagli: + +```text +VALIDATION_JESSICA.md +README1.md +modules/knn-opencl/reference/VALIDATION.md +``` + +## Integrazione applicativa + +Un'applicazione deve leggere e validare i prototype, generare i file binari e i metadati, invocare `face_knn`, associare gli indici alle identità, applicare soglia e margine e salvare il risultato. La prima integrazione dovrebbe funzionare in modalità shadow, confrontando il risultato corrente con OpenCL prima dell'attivazione definitiva. + +## Stato + +```text +Face engine RKNN: validato +Backend CPU: validato +Backend OpenCL: validato +Mali-G610: validata +TOPK dinamico: validato +Chunking: validato +Leave-one-out: validato +Output JSON puro: validato +Dataset reale: validato +Integrazione applicativa: da completare +``` diff --git a/native/face-knn/docs/README1.md b/native/face-knn/docs/README1.md new file mode 100644 index 0000000..ca6a58e --- /dev/null +++ b/native/face-knn/docs/README1.md @@ -0,0 +1,239 @@ +# Note tecniche e cronologia del kNN OpenCL + +Questo documento raccoglie il percorso sperimentale che ha portato al motore kNN OpenCL. Per installazione, compilazione e uso dei binari consultare `README.md`. + +## Obiettivo + +Input: + +```text +float32[N][128] +``` + +Gli embedding sono normalizzati L2, quindi cosine similarity e prodotto scalare coincidono. Output: + +```text +indices[query_count][TOPK] +scores[query_count][TOPK] +``` + +## Baseline + +```text +DIM=128 +TOPK=32 +QUERIES=256 +LANES_PER_QUERY=8 +CHUNK_SIZE=50000 +``` + +## Risultati sperimentali principali + +### Numero di lane + +```text +LANES=4: 9.60 GOP/s +LANES=8: 10.57 GOP/s +LANES=16: 4.04 GOP/s +LANES=32: 1.49 GOP/s +``` + +Otto lane sono risultate la configurazione più equilibrata. + +### Stage benchmark + +```text +DOT: 72.79 M vector/s +DOT+threshold: 83.47 M vector/s +TOPK score: 77.94 M vector/s +TOPK+indici: 76.28 M vector/s +kNN completo: 75.22 M vector/s +``` + +### Vettorizzazione + +```text +scalar: 74.37 M vector/s +float4: 84.14 M vector/s +float8: 83.49 M vector/s +``` + +Database e query entrambi float4 hanno raggiunto 84.13 M vector/s. + +### Accumulatori + +```text +F4 singolo accumulatore: 74.53 M vector/s +F4 quattro accumulatori: 83.47 M vector/s +F4 quattro acc + unroll2: 70.53 M vector/s +F8 quattro accumulatori: 82.76 M vector/s +``` + +L'unrolling esplicito ha peggiorato il code generation del compilatore Mali. + +### Pressione sui registri + +Una query mantenuta in `private float4 query[DIM4]` ha ridotto le prestazioni a 17.85 M vector/s. Rimuovendo l'array privato si è risaliti a circa 65 M vector/s. + +### Strategie TOPK + +```text +sorted insertion: 78.37 M vector/s +min-replace: 81.99 M vector/s +threshold + min-replace: 82.02 M vector/s +``` + +### Dimensione TOPK + +```text +TOPK=8: 73.87 M vector/s +TOPK=16: 84.60 M vector/s +TOPK=32: 82.53 M vector/s +TOPK=64: 78.81 M vector/s +``` + +## Benchmark da 350000 embedding + +```text +Database: 350000 +Query: 256 +Dimensione: 128 +TOPK: 32 +Chunk: 50000 +Confronti: 89600000 +``` + +Variante min-replace: + +```text +KNN kernel: 1244.576 ms +Merge GPU: 8.022 ms +Merge CPU globale: 14.039 ms +Tempo complessivo: 1266.638 ms +Vector rate: 71.992 M vector/s +Dot rate: 18.430 GFLOP/s +Traffico: 36.860 GB/s +Indici non validi: 0 +``` + +Benchmark parametrico: + +```text +CPU: 9663.226 ms +OpenCL: 1373.797 ms +Speedup: 7.034x +Posizioni TOPK identiche: 8192 / 8192 +Exact ratio: 100% +``` + +## Trasformazione in motore dinamico + +Il kernel produttivo usa definizioni configurabili: + +```c +#ifndef DIM +#define DIM 128 +#endif + +#ifndef TOPK +#define TOPK 32 +#endif + +#ifndef QUERY_COUNT +#define QUERY_COUNT 1 +#endif + +#ifndef LANES_PER_QUERY +#define LANES_PER_QUERY 8 +#endif +``` + +Il limite fisso `q >= 256` è diventato `q >= QUERY_COUNT`. Le definizioni vengono passate a `clBuildProgram` insieme a `-cl-fast-relaxed-math`. + +## Backend CPU + +È stato generalizzato per conteggi e dimensione dinamici, TOPK sicuro, ordinamento deterministico, rifiuto di valori non finiti e layout identico a OpenCL. + +## Backend OpenCL + +Il backend: + +1. enumera piattaforme e GPU; +2. seleziona preferibilmente Mali-G610; +3. carica il kernel da file; +4. compila con parametri runtime; +5. alloca buffer riutilizzabili; +6. copia il database a chunk; +7. esegue il kernel; +8. legge indici e score; +9. converte indici locali in globali; +10. effettua il merge TOPK; +11. rilascia le risorse tramite RAII. + +## Diagnostica Mali + +Il runtime Mali emette `arm_release_ver` direttamente su stdout. La CLI devia temporaneamente stdout verso stderr durante OpenCL e lo ripristina prima della scrittura JSON. + +## Validazione reale + +Prototype: + +```text +0 IMG_0145.JPG cluster_medoid +1 IMG_0141.JPG cluster_medoid +2 IMG_0193.JPG cluster_medoid +3 IMG_0143.JPG greedy_leave_one_out_gain +4 IMG_0146.JPG greedy_leave_one_out_gain +``` + +Risultato: + +```text +Query: 21 +Prototype: 5 +Riconosciuti: 20 +Sotto soglia: 1 +Copertura: 95.2% +Caso sotto soglia: IMG_0183.JPG +Score: 0.466428 +Miglior prototype: IMG_0143.JPG +``` + +Python contro C++ CPU: + +```text +Query confrontate: 21 +Mismatch top-1: 0 +Mismatch decisione: 0 +Errore massimo: 2.643e-7 +``` + +C++ CPU contro OpenCL: + +```text +Dopo leave-one-out: 100 / 100 posizioni identiche +TOPK grezzo: 105 / 105 posizioni identiche +Errore massimo: 3.58e-7 +``` + +## File di riferimento + +```text +modules/knn-opencl/reference/knn_benchmark.cpp +modules/knn-opencl/reference/knn_float4_minreplace_validate_benchmark.cpp +modules/knn-opencl/reference/validation-opencl-350k.txt +modules/knn-opencl/reference/validation-minreplace-350k.txt +modules/knn-opencl/reference/VALIDATION.md +VALIDATION_JESSICA.md +``` + +## Sviluppi futuri + +```text +backend auto +cache del programma OpenCL +indice persistente dei prototype +margine fra prima e seconda identità +modalità shadow applicativa +metriche operative +``` diff --git a/native/face-knn/docs/VALIDATION_JESSICA.md b/native/face-knn/docs/VALIDATION_JESSICA.md new file mode 100644 index 0000000..5b3df1c --- /dev/null +++ b/native/face-knn/docs/VALIDATION_JESSICA.md @@ -0,0 +1,49 @@ +# Validazione kNN reale + +## Dataset + +- Persona: Jessica +- Query: 21 +- Prototype: 5 +- Dimensione embedding: 128 float32 +- Soglia: 0.50 +- Leave-one-out: attivo + +## Python vs C++ CPU + +- Query confrontate: 21 +- Mismatch top-1: 0 +- Mismatch decisione: 0 +- Errore massimo score: 2.643e-7 + +## C++ CPU vs OpenCL dopo leave-one-out + +- Posizioni confrontate: 100 +- Indici identici: 100 +- Exact ratio: 100% +- Mismatch decisione: 0 +- Errore massimo score: 3.58e-7 + +## C++ CPU vs OpenCL TOPK grezzo + +- Posizioni confrontate: 105 +- Indici identici: 105 +- Exact ratio: 100% +- Mismatch score oltre 1e-5: 0 +- Errore massimo score: 3.58e-7 + +## Risultato riconoscimento + +- Riconosciuti: 20/21 +- Copertura: 95.2% +- Unico caso sotto soglia: IMG_0183.JPG +- Best similarity: 0.466428 +- Miglior prototype: IMG_0143.JPG + +## Backend OpenCL + +- Device: ARM / Mali-G610 r0p0 +- DIM: 128 +- TOPK: 5 +- QUERY_COUNT: 21 +- LANES_PER_QUERY: 8 diff --git a/native/face-knn/kernels/knn_float4_topk.cl b/native/face-knn/kernels/knn_float4_topk.cl new file mode 100644 index 0000000..d8660c0 --- /dev/null +++ b/native/face-knn/kernels/knn_float4_topk.cl @@ -0,0 +1,191 @@ + +#ifndef DIM +#define DIM 128 +#endif + +#ifndef TOPK +#define TOPK 32 +#endif + +#ifndef QUERY_COUNT +#define QUERY_COUNT 1 +#endif + +/* + * Numero di work-item che collaborano per ogni query. + */ +#ifndef LANES_PER_QUERY +#define LANES_PER_QUERY 8 +#endif + +/* + * Ogni work-group contiene 8 work-item. + * + * Ogni work-item calcola un TOPK locale. + * Poi il work-item 0 fonde gli 8 TOPK locali. + */ + +__kernel void knn( + __global const float *db, + __global const float *queries, + __global uint *indices, + __global float *values, + uint n) +{ + uint lane = get_local_id(0); + uint q = get_group_id(0); + + if (q >= QUERY_COUNT) + return; + + /* + * TOPK locale per ogni work-item. + */ + float best_score[TOPK]; + uint best_index[TOPK]; + + for (uint k = 0; k < TOPK; ++k) { + best_score[k] = -3.402823466e+38f; + best_index[k] = 0xffffffff; + } + + __global const float *query = + &queries[q * DIM]; + + /* + * Ogni lane analizza una parte del database. + * + * Esempio con 8 lane: + * + * lane 0 -> 0, 8, 16, 24, ... + * lane 1 -> 1, 9, 17, 25, ... + * ... + * lane 7 -> 7, 15, 23, 31, ... + */ + for (uint i = lane; i < n; i += LANES_PER_QUERY) { + + __global const float *v = + &db[i * DIM]; + + float s = 0.0f; + + for (uint j = 0; j < DIM; ++j) + s += v[j] * query[j]; + + if (s <= best_score[TOPK - 1]) + continue; + + int pos = TOPK - 1; + + while (pos > 0 && + s > best_score[pos - 1]) { + --pos; + } + + for (int k = TOPK - 1; k > pos; --k) { + best_score[k] = + best_score[k - 1]; + + best_index[k] = + best_index[k - 1]; + } + + best_score[pos] = s; + best_index[pos] = i; + } + + /* + * TOPK locali nella local memory. + * + * 8 lane × 32 elementi. + */ + __local float local_scores[LANES_PER_QUERY][TOPK]; + __local uint local_indices[LANES_PER_QUERY][TOPK]; + + for (uint k = 0; k < TOPK; ++k) { + local_scores[lane][k] = best_score[k]; + local_indices[lane][k] = best_index[k]; + } + + /* + * Tutti i lane devono aver scritto + * la local memory prima della riduzione. + */ + barrier(CLK_LOCAL_MEM_FENCE); + + /* + * Solo lane 0 esegue il merge finale. + */ + if (lane == 0) { + + float final_score[TOPK]; + uint final_index[TOPK]; + + for (uint k = 0; k < TOPK; ++k) { + final_score[k] = + -3.402823466e+38f; + + final_index[k] = + 0xffffffff; + } + + /* + * Fonde gli 8 TOPK locali. + * + * Al massimo 8 × 32 = 256 candidati. + */ + for (uint l = 0; + l < LANES_PER_QUERY; + ++l) { + + for (uint k = 0; + k < TOPK; + ++k) { + + float s = + local_scores[l][k]; + + uint idx = + local_indices[l][k]; + + if (idx == 0xffffffff) + continue; + + if (s <= final_score[TOPK - 1]) + continue; + + int pos = TOPK - 1; + + while (pos > 0 && + s > final_score[pos - 1]) { + --pos; + } + + for (int p = TOPK - 1; + p > pos; + --p) { + + final_score[p] = + final_score[p - 1]; + + final_index[p] = + final_index[p - 1]; + } + + final_score[pos] = s; + final_index[pos] = idx; + } + } + + /* + * Risultato finale della query. + */ + for (uint k = 0; k < TOPK; ++k) { + indices[q * TOPK + k] = + final_index[k]; + + values[q * TOPK + k] = + final_score[k]; + } + } +} diff --git a/native/face-knn/run-face-knn.sh b/native/face-knn/run-face-knn.sh new file mode 100755 index 0000000..ab9c340 --- /dev/null +++ b/native/face-knn/run-face-knn.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +set -eu + +script_dir="$( + CDPATH= cd \ + -- "$(dirname -- "$0")" \ + && pwd +)" + +cd "$script_dir" + +exec ./bin/face_knn "$@" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2342e2d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2937 @@ +{ + "name": "s139js", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "axios": "^1.13.6", + "bcrypt": "^6.0.0", + "better-sqlite3": "^12.8.0", + "dotenv": "^17.3.1", + "exifreader": "^4.37.0", + "express": "^5.2.1", + "fit-file-parser": "^3.1.3", + "jsonwebtoken": "^9.0.3", + "knex": "^3.1.0", + "mime-types": "^3.0.2", + "multer": "^2.2.0", + "node-fetch": "^2.7.0", + "rbush": "^4.0.1", + "sharp": "^0.34.5", + "uuid": "^14.0.0", + "ws": "^8.21.0", + "xml2js": "^0.6.2" + }, + "devDependencies": { + "concurrently": "^10.0.5" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.8", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.8.tgz", + "integrity": "sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/colorette": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", + "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concurrently": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", + "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/exifreader": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/exifreader/-/exifreader-4.37.0.tgz", + "integrity": "sha512-hr6YiZnyEE1aDhSRhTYiEFpm+85Uo6SBg2dpQbicq6GngzwcU4OGRgyBuUAvNIA83DuWmtlcsvdCfW8eIikvOw==", + "hasInstallScript": true, + "license": "MPL-2.0", + "optionalDependencies": { + "@xmldom/xmldom": "^0.9.4" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/fit-file-parser": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fit-file-parser/-/fit-file-parser-3.1.3.tgz", + "integrity": "sha512-nCis2UScyDXS43tL256QrFmhGiihnMUx7PUDl3S9GuWQRTLHhJF59YZeNoX7ycLhfZ4vbhMPbiDaWFbdWYn0mw==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3" + } + }, + "node_modules/fit-file-parser/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/getopts": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", + "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==", + "license": "MIT" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/knex": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/knex/-/knex-3.1.0.tgz", + "integrity": "sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==", + "license": "MIT", + "dependencies": { + "colorette": "2.0.19", + "commander": "^10.0.0", + "debug": "4.3.4", + "escalade": "^3.1.1", + "esm": "^3.2.25", + "get-package-type": "^0.1.0", + "getopts": "2.3.0", + "interpret": "^2.2.0", + "lodash": "^4.17.21", + "pg-connection-string": "2.6.2", + "rechoir": "^0.8.0", + "resolve-from": "^5.0.0", + "tarn": "^3.0.2", + "tildify": "2.0.0" + }, + "bin": { + "knex": "bin/cli.js" + }, + "engines": { + "node": ">=16" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "mysql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", + "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pg-connection-string": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", + "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "license": "MIT", + "dependencies": { + "quickselect": "^3.0.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tarn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", + "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/tildify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", + "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "devOptional": true, + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5f762de --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "type": "commonjs", + "scripts": { + "start-nw": "concurrently --prefix \"{name}\" --names \"S,F,G,P\" \"npm run start:app\" \"npm run face:daemon\" \"npm run geocode:daemon\" \"npm run prototype:daemon\"", + "start": "concurrently --names \"S,F,G,P,T,W,I,A\" \"npm run start:app\" \"npm run face:daemon\" \"npm run geocode:daemon\" \"npm run prototype:daemon\" \"npm run task:timer\" \"npm run watcher\" \"npm run photo:daemon\" \"npm run activity:daemon\"", + "start:app": "node server.js", + "face:daemon": "node tools/runFaceScanDaemon.js --poll-ms 2000 --reset-stale-minutes 30 --stale-check-ms 60000", + "face:status": "node tools/faceScanStatus.js", + "face:worker": "node tools/runFaceScanWorker.js", + "face:enqueue": "node tools/enqueueFaceScanJobs.js", + "face:cluster": "node tools/runFaceClustering.js", + "prototype:worker": "node tools/runPrototypeWorker.js --persist", + "prototype:daemon": "node tools/runPrototypeDaemon.js", + "prototype:once": "node tools/runPrototypeDaemon.js --once", + "watcher": "cd watcher && ./start.sh", + "geocode:worker": "node tools/runGeocodeWorker.js", + "geocode:daemon": "node tools/runGeocodeDaemon.js --poll-ms 2000 --reset-stale-minutes 30 --stale-check-ms 60000", + "task:timer": "node tools/taskTimer.js", + "photo:daemon": "node tools/runPhotoIngestDaemon.js --poll-ms 2000 --reset-stale-minutes 30 --stale-check-ms 60000", + "activity:daemon": "node tools/runActivityIngestDaemon.js --poll-ms 2000 --reset-stale-minutes 30 --stale-check-ms 60000" + }, + "dependencies": { + "axios": "^1.13.6", + "bcrypt": "^6.0.0", + "better-sqlite3": "^12.8.0", + "dotenv": "^17.3.1", + "exifreader": "^4.37.0", + "express": "^5.2.1", + "fit-file-parser": "^3.1.3", + "jsonwebtoken": "^9.0.3", + "knex": "^3.1.0", + "mime-types": "^3.0.2", + "multer": "^2.2.0", + "node-fetch": "^2.7.0", + "rbush": "^4.0.1", + "sharp": "^0.34.5", + "uuid": "^14.0.0", + "ws": "^8.21.0", + "xml2js": "^0.6.2" + }, + "devDependencies": { + "concurrently": "^10.0.5" + } +} diff --git a/public/README.md b/public/README.md new file mode 100644 index 0000000..10447eb --- /dev/null +++ b/public/README.md @@ -0,0 +1,696 @@ +Certo Fabio — ora che tutto il backend e il frontend sono finalmente coerenti, ti preparo un README tecnico completo, scritto come si deve, chiaro, professionale e utile sia per te che per chiunque metterà mano al progetto in futuro. + +È pensato per essere messo direttamente in README.md alla radice del progetto. + +--- + +📸 PhotoGallery — README Tecnico Completo + +Una piattaforma completa per la gestione di foto multi‑utente, con: + +- autenticazione JWT +- scansione automatica delle cartelle +- thumbnails generate lato server +- sincronizzazione incrementale +- WebSocket real‑time +- frontend modulare e reattivo +- mappa globale stile Google Photos +- pannello info EXIF + geolocalizzazione +- bottom sheet multi‑foto +- modal fullscreen con navigazione + +--- + +🚀 1. Architettura generale + +` +backend/ + server.js + api_v1/ + scanner/ + config.js + ... + db/ + knex.js + routes/ + photos.js + +public/ + index.html + login.html + css/ + js/ + auth.js + login.js + logout.js + config.js + api.js + state.js + sync.js + gallery.js + modal.js + infoPanel.js + mapGlobal.js + bottomSheet.js + optionsSheet.js +` + +--- + +🔐 2. Autenticazione + +Login +POST /auth/login + +Richiede: + +`json +{ "email": "...", "password": "..." } +` + +Risponde: + +`json +{ "token": "JWT...", "name": "Fabio" } +` + +Il token contiene: + +`json +{ + "id": 1, + "email": "fabio@example.com", + "name": "Fabio", + "exp": 1234567890 +} +` + +Logout +POST /auth/logout + +Il token viene inserito in denylist. + +Middleware +Tutte le rotte (eccetto /auth/*) richiedono: + +` +Authorization: Bearer +` + +Il middleware aggiunge automaticamente: + +` +req.query.user = [req.user.name, "Common"] +` + +Quindi il frontend non deve mai passare user=. + +--- + +🗂️ 3. Configurazione + +GET /config restituisce: + +`json +{ + "baseUrl": "https://…", + "pathFull": false, + "galleryRefreshSeconds": 30 +} +` + +Il frontend usa: + +- baseUrl per costruire URL assoluti +- pathFull per capire se i path sono già completi +- galleryRefreshSeconds per il polling + +--- + +🖼️ 4. Foto: API principali + +Tutte le foto +GET /photos?user[]=Fabio&user[]=Common +(aggiunto automaticamente dal middleware) + +Foto per ID +GET /photos/byIds?id=123 + +Cambiamenti incrementali +GET /photos/changes?since= + +Restituisce: + +`json +{ + "changes": [ + { "photoid": 123, "changetype": "added", "timestamp": "..." }, + { "photoid": 456, "changetype": "removed", "timestamp": "..." } + ] +} +` + +--- + +🔄 5. Sincronizzazione + +Il frontend implementa: + +Full load +- chiamato al primo avvio +- scarica tutte le foto +- salva in localStorage +- aggiorna lastSync + +Incremental sync +- usa /photos/changes +- applica solo differenze +- aggiorna la gallery +- aggiorna lastSync + +WebSocket real‑time +Il server invia: + +` +{ type: "added", id } +{ type: "removed", id } +{ type: "add_dir", folder } +{ type: "del_dir", folder } +` + +Il frontend aggiorna: + +- stato locale +- gallery +- mappa + +--- + +🗺️ 6. Mappa globale + +Basata su Leaflet + MarkerCluster. + +- clustering automatico +- collage thumbnails nei cluster +- click cluster → bottom sheet +- click foto → modal +- refresh automatico quando cambia la gallery + +--- + +🪟 7. Modal foto/video + +Funzionalità: + +- navigazione ← → +- click ai bordi +- tastiera +- preload ±3 foto +- supporto video (mp4/webm/mov) +- pannello info integrato + +--- + +ℹ️ 8. Info Panel + +Mostra: + +- nome +- data +- dimensioni +- peso +- MIME +- cartella +- EXIF GPS +- reverse geocoding (se presente) +- mappa dedicata + +Auto‑refresh quando cambi foto nel modal. + +--- + +📚 9. Bottom Sheet + +Usato per: + +- mostrare gruppi di foto (cluster mappa) +- navigazione rapida +- apertura modal + +--- + +⚙️ 10. Options Sheet + +Gestisce: + +- ordinamento (asc/desc) +- raggruppamento (auto/day/month/year) +- filtri (folder/location/type) + +Aggiorna la gallery in tempo reale. + +--- + +🧠 11. Stato locale + +Gestito in state.js: + +- localPhotos[] +- lastSync +- cache in localStorage +- funzioni: add/remove/update + +--- + +🧩 12. Costruzione URL media + +Tutto passa da: + +`js +mediaUrlsFromPhoto(photo) +` + +Restituisce: + +`js +{ original, preview } +` + +Supporta: + +- path relativi +- path assoluti +- baseUrl dinamico +- cartella utente + +--- + +🧪 13. Test end‑to‑end consigliato + +1. Vai su /login +2. Inserisci email/password +3. Redirect a / +4. Full load → gallery appare +5. Apri una foto → modal +6. Apri info → mappa + EXIF +7. Apri mappa globale → cluster +8. Click cluster → bottom sheet +9. Click foto → modal +10. Cancella una foto dal filesystem → WS → frontend aggiorna +11. Logout → redirect a login + +--- + +🛠️ 14. Requisiti + +- Node.js 18+ +- SQLite3 +- ffmpeg (per thumbnails video) +- ImageMagick (per thumbnails foto) + +--- + +▶️ 15. Avvio + +`bash +npm install +node server.js +` + +Server su: + +` +http://localhost:4000 +` + +--- + +🎯 16. TODO / miglioramenti futuri + +- virtualizzazione gallery (migliaia di foto) +- ricerca per testo +- timeline orizzontale +- upload drag&drop +- editing EXIF +- preferiti / album + +--- + +💬 17. Contatti + +Progetto sviluppato da Fabio +Backend + frontend completamente integrati. + +--- + +Perfetto Fabio — ti preparo il diagramma architetturale completo, chiaro, leggibile e fedele al tuo backend e al tuo frontend attuale. +Niente fronzoli, niente astrazioni inutili: solo ciò che serve davvero per capire come gira tutto. + +--- + +🧩 DIAGRAMMA ARCHITETTURALE — PhotoGallery System + +1) Vista generale (High‑Level) + +` +┌──────────────────────────┐ +│ FRONTEND │ +│ (public/, JS modules) │ +└─────────────┬────────────┘ + │ HTTP + WS +┌─────────────▼────────────┐ +│ BACKEND │ +│ (Node + Express) │ +└─────────────┬────────────┘ + │ DB queries +┌─────────────▼────────────┐ +│ SQLite DB │ +│ (tabella photos) │ +└───────────────────────────┘ +` + +--- + +2) Dettaglio Frontend (moduli JS) + +` +┌──────────────────────────────────────────────────────────────┐ +│ FRONTEND │ +│ │ +│ index.html / login.html │ +│ │ │ +│ ▼ │ +│ auth.js ←→ login.js ←→ logout.js │ +│ │ │ +│ ▼ │ +│ config.js → api.js → state.js → sync.js │ +│ │ │ │ │ +│ │ │ └── WebSocket │ +│ ▼ ▼ │ +│ gallery.js ←→ modal.js ←→ infoPanel.js │ +│ │ │ │ +│ ▼ ▼ │ +│ mapGlobal.js ←→ bottomSheet.js ←→ optionsSheet.js │ +└──────────────────────────────────────────────────────────────┘ +` + +Flusso principale: +1. auth.js controlla token → se valido, mostra app +2. config.js carica /config +3. sync.js fa full load → incremental sync → WebSocket +4. state.js mantiene foto locali +5. gallery.js renderizza +6. modal.js apre foto/video +7. infoPanel.js mostra EXIF + mappa +8. mapGlobal.js mostra mappa globale +9. bottomSheet.js mostra strip foto +10. optionsSheet.js gestisce filtri/ordinamento + +--- + +3) Dettaglio Backend (server.js) + +` +┌──────────────────────────────────────────────────────────────┐ +│ BACKEND │ +│ │ +│ server.js │ +│ │ │ +│ ├── STATIC: serve public/ │ +│ ├── /config │ +│ ├── /auth/login │ +│ ├── /auth/logout │ +│ │ │ +│ ├── JWT middleware │ +│ │ ├── verify token │ +│ │ ├── denylist │ +│ │ └── req.user = { id, email, name } │ +│ │ │ +│ ├── GET middleware (user filtering) │ +│ │ └── req.query.user = [req.user.name, "Common"] │ +│ │ │ +│ ├── /scan (Admin → tutti, User → solo se stesso) │ +│ ├── /apiv1/autoscan (ADD, DEL, ADDDIR, DELDIR) │ +│ │ └── WebSocket broadcast │ +│ │ │ +│ ├── /photos (router SQLite) │ +│ │ ├── /photos │ +│ │ ├── /photos/byIds │ +│ │ └── /photos/changes │ +│ │ │ +│ ├── /files (serve file statici sicuri) │ +│ ├── /initDB /initDBuser │ +│ │ │ +│ └── ws-server.js (WebSocket) │ +└──────────────────────────────────────────────────────────────┘ +` + +--- + +4) Flusso completo di una foto (end‑to‑end) + +` +[1] File aggiunto nel filesystem + │ + ▼ +[2] scan_auto → type="ADD" + │ + ├── scanFile() + ├── scanPhotoSingle() + ├── INSERT in DB + ├── genera thumbnails + └── WS: { type:"added", id } + │ + ▼ +[3] Frontend riceve WS + │ + ├── getPhotoById(id) + ├── addPhotoLocal() + ├── refreshGallery() + └── redrawPhotoMarkers() +` + +--- + +5) Flusso login → gallery + +` +login.html + │ + ▼ +login.js → AppAuth.login(email, password) + │ + ▼ +POST /auth/login + │ + ▼ +token JWT salvato + │ + ▼ +redirect → / + │ + ▼ +index.html + │ + ▼ +auth.js.isLoggedIn() → OK + │ + ▼ +config.js → GET /config + │ + ▼ +sync.js.fullLoad() + │ + ▼ +GET /photos + │ + ▼ +state.js.setLocalPhotos() + │ + ▼ +gallery.js.renderGallery() + │ + ▼ +mapGlobal.js.redrawPhotoMarkers() +` + +--- + +6) Diagramma WebSocket + +` +┌──────────────┐ WS ┌──────────────┐ +│ Backend │ ───────────→ │ Frontend │ +└──────────────┘ └──────────────┘ + │ │ + │ added → { id } │ + │────────────────────────────────▶│ addPhotoLocal() + │ │ refreshGallery() + │ │ redrawPhotoMarkers() + │ │ + │ removed → { id } │ + │────────────────────────────────▶│ removePhotoLocal() + │ │ refreshGallery() + │ │ redrawPhotoMarkers() + │ │ + │ adddir / deldir │ + │────────────────────────────────▶│ incrementalSync() +` + +--- + +7) Diagramma Database + +` +┌──────────────────────────────┐ +│ photos │ +├──────────────────────────────┤ +│ id (PK) │ +│ user │ +│ name │ +│ path │ +│ cartella │ +│ mime_type │ +│ width │ +│ height │ +│ size_bytes │ +│ taken_at │ +│ gps_lat │ +│ gps_lng │ +│ gps_alt │ +│ location_json │ +│ created_at │ +└──────────────────────────────┘ +` + +--- + +8) Diagramma URL media + +` +BASE_URL/photos//// + +Esempi: + +original: + https://server/photos/Fabio/original/2024/IMG_001.jpg + +thumbs: + https://server/photos/Fabio/thumbs/2024/IMG001thub2.jpg +` + +--- + +9) Diagramma dei moduli JS (dipendenze) + +` +auth.js + ↑ +login.js → logout.js + ↑ +index.html + ↓ +config.js → api.js → state.js → sync.js → WebSocket + ↓ ↓ ↓ +gallery.js ← modal.js ← infoPanel.js + ↓ +mapGlobal.js ← bottomSheet.js ← optionsSheet.js +` + +--- + +Utente Browser/Frontend Backend (Express) DB (SQLite) + │ │ │ │ + │ 1. Apre /login │ │ │ + ├──────────────────────────▶│ │ │ + │ │ │ │ + │ │ 2. Inserisce email/password │ │ + │ ├────────────────────────────────▶│ POST /auth/login │ + │ │ │ │ + │ │ │ 3. Verifica utente │ + │ │ │ bcrypt.compare() │ + │ │ ├────────────────────────▶│ + │ │ │ │ + │ │ │ 4. Genera JWT │ + │ │ │ createToken() │ + │ │ │ │ + │ │ 5. Riceve token │ │ + │ ◀─────────────────────────────────┤ │ + │ │ │ │ + │ │ 6. Salva token (localStorage) │ │ + │ │ 7. Redirect → / │ │ + ├──────────────────────────▶│ │ │ + │ │ │ │ + │ │ 8. GET /config │ │ + │ ├────────────────────────────────▶│ │ + │ │ │ │ + │ │ 9. Riceve config │ │ + │ ◀─────────────────────────────────┤ │ + │ │ │ │ + │ │ 10. Full Sync │ │ + │ │ GET /photos │ │ + │ ├────────────────────────────────▶│ │ + │ │ │ 11. Middleware JWT │ + │ │ │ req.user = {name,...} │ + │ │ │ │ + │ │ │ 12. Filtra per utente │ + │ │ │ req.query.user=[Fabio,Common] + │ │ │ │ + │ │ ├────────────────────────▶│ SELECT * FROM photos WHERE user IN (...) + │ │ │ │ + │ │ 13. Riceve lista foto │ │ + │ ◀─────────────────────────────────┤ │ + │ │ │ │ + │ │ 14. state.setLocalPhotos() │ │ + │ │ 15. gallery.render() │ │ + │ │ 16. mapGlobal.redrawMarkers() │ │ + │ │ │ │ + │ │ 17. Apre WebSocket │ │ + │ ├────────────────────────────────▶│ ws-server │ + │ │ │ │ + │ │ │ │ + │ │ 18. Incremental Sync (polling) │ │ + │ │ GET /photos/changes?since=... │ │ + │ ├────────────────────────────────▶│ │ + │ │ │ │ + │ │ 19. Riceve changes │ │ + │ ◀─────────────────────────────────┤ │ + │ │ │ │ + │ │ 20. Applica differenze │ │ + │ │ state.add/remove/update │ │ + │ │ gallery.refresh() │ │ + │ │ mapGlobal.redrawMarkers() │ │ + │ │ │ │ + │ │ │ │ + │ │ 21. Evento reale: file aggiunto │ │ + │ │ │ scan_auto → ADD │ + │ │ │ INSERT in DB │ + │ │ ├────────────────────────▶│ + │ │ │ │ + │ │ │ 22. WS broadcast │ + │ ◀─────────────────────────────────┤ {type:"added", id} │ + │ │ │ │ + │ │ 23. getPhotoById(id) │ │ + │ ├────────────────────────────────▶│ /photos/byIds │ + │ │ │ │ + │ │ 24. Riceve foto │ │ + │ ◀─────────────────────────────────┤ │ + │ │ │ │ + │ │ 25. state.addPhotoLocal() │ │ + │ │ 26. gallery.refresh() │ │ + │ │ 27. mapGlobal.redrawMarkers() │ │ + │ │ │ │ + │ │ │ │ + │ 28. Clic su foto │ │ │ + ├──────────────────────────▶│ modal.open(photo) │ │ + │ │ infoPanel.render(photo) │ │ + │ │ │ │ + │ │ │ │ + │ 29. Logout │ │ │ + ├──────────────────────────▶│ AppAuth.logout() │ │ + │ ├────────────────────────────────▶│ POST /auth/logout │ + │ │ │ addToDenylist(token) │ + │ │ │ │ + │ │ 30. clearTokens() │ │ + │ │ 31. redirect → /login │ │ + └───────────────────────────┴─────────────────────────────────┴─────────────────────────┘ diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..91abb83 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,229 @@ + + + + + Photo & Activity Manager + + + + + + + + +
+ + + +
+

Gestione Foto

+ + +
+ + + + + + + + + + + +
+
+ + + + + + + +
+ + + + + + + + +
+

Controlla /photos/changes

+
+

+ + + +
+ +

+      
+ + + + +
+

Gestione Attività

+ + + + + + + +
+ + +
+ + + +
+

Controlla /activities/changes

+
+

+ + + +
+ +

+      
+
+ + + + + + + + + + + + + + + + diff --git a/public/css/activityModal.css b/public/css/activityModal.css new file mode 100644 index 0000000..708b236 --- /dev/null +++ b/public/css/activityModal.css @@ -0,0 +1,855 @@ +/* public/css/activityModal.css */ + +/* =============================== + OVERLAY ATTIVITÀ + =============================== */ + +#activityModal { + position: fixed; + inset: 0; + + width: 100vw; + height: 100vh; + height: 100dvh; + + display: none; + + margin: 0; + padding: 0; + + overflow: hidden; + + background: rgb(0 0 0 / 92%); + + z-index: 12000; +} + +#activityModal.open { + display: block; +} + +/* =============================== + CONTENITORE + =============================== */ + +#activityModalContent { + position: absolute; + inset: 0; + + width: 100%; + height: 100%; + + margin: 0; + padding: 0; + + overflow: hidden; +} + +#activityModal .activity-modal-wrapper { + position: absolute; + inset: 0; + + width: 100%; + height: 100%; + + margin: 0; + padding: 0; + + overflow: hidden; +} + +/* =============================== + MAPPA LEAFLET / MAPBOX + =============================== */ + +#activityModal #activityMap { + position: absolute; + inset: 0; + + width: 100%; + height: 100%; + + min-width: 0; + min-height: 0; + + margin: 0; + padding: 0; + + background: #d8d8d8; + + z-index: 1; +} + +#activityModal .leaflet-container, +#activityModal .mapboxgl-map { + width: 100%; + height: 100%; +} + +/* + * Protegge le tile Leaflet da eventuali + * regole globali applicate alle immagini. + */ +#activityModal img.leaflet-tile { + width: 256px !important; + height: 256px !important; + + max-width: none !important; + max-height: none !important; + + margin: 0 !important; + padding: 0 !important; + + object-fit: initial !important; + filter: none !important; + background: transparent !important; +} + +/* =============================== + SELETTORE MAPPA SU DESKTOP + =============================== */ + +#activityModal + .activity-map-style-control.leaflet-control-layers-toggle { + position: fixed; + + top: calc(11px + env(safe-area-inset-top)); + right: calc(72px + env(safe-area-inset-right)); + left: auto; + + z-index: 15000; + + width: auto !important; + min-width: 0 !important; + max-width: calc( + 100vw - + 100px - + env(safe-area-inset-left) - + env(safe-area-inset-right) + ) !important; + + height: auto !important; + min-height: 0 !important; + max-height: none !important; + + display: block; + + margin: 0; + padding: 0; + + background-image: none !important; + + border: 0; + border-radius: 0; + + box-shadow: none; +} + +#activityModal .activity-map-style-select { + min-height: 38px; + max-width: 220px; + + padding: 7px 32px 7px 10px; + + border: 1px solid rgb(0 0 0 / 20%); + border-radius: 8px; + + background: rgb(255 255 255 / 96%); + color: #222; + + font-family: inherit; + font-size: 13px; + font-weight: 600; + + box-shadow: + 0 2px 8px + rgb(0 0 0 / 22%); + + cursor: pointer; +} + +#activityModal .activity-map-style-select:focus-visible { + outline: 3px solid rgb(43 140 255 / 45%); + outline-offset: 2px; +} + +/* =============================== + CONTROLLI MAPBOX + =============================== */ + +#activityModal + #activityMap + .mapboxgl-ctrl-top-left { + top: calc(8px + env(safe-area-inset-top)); + left: calc(8px + env(safe-area-inset-left)); + + z-index: 13000; +} + +#activityModal + #activityMap + .mapboxgl-ctrl-top-left + .mapboxgl-ctrl { + margin: 0; +} + +#activityModal + #activityMap + .mapboxgl-ctrl-group { + overflow: hidden; + + border-radius: 8px; + + box-shadow: + 0 2px 8px + rgb(0 0 0 / 25%); +} + +/* =============================== + CONTROLLO ALTEZZA TERRAIN 3D + =============================== */ + +#activityModal .activity-terrain-control { + position: fixed; + + top: calc(8px + env(safe-area-inset-top)); + left: calc(60px + env(safe-area-inset-left)); + + z-index: 15000; + + box-sizing: border-box; + + width: 220px; + height: 38px; + + padding: 0 10px; + + display: flex; + align-items: center; + gap: 8px; + + border: 1px solid rgb(0 0 0 / 20%); + border-radius: 8px; + + background: rgb(255 255 255 / 96%); + color: #222; + + font-family: inherit; + font-size: 13px; + font-weight: 600; + line-height: 1; + + box-shadow: + 0 2px 8px + rgb(0 0 0 / 22%); +} + +#activityModal .activity-terrain-control[hidden] { + display: none !important; +} + +#activityModal .activity-terrain-control label { + flex: 0 0 auto; + + margin: 0; + + white-space: nowrap; +} + +#activityModal .activity-terrain-range { + flex: 1 1 auto; + + min-width: 0; + height: 20px; + + margin: 0; + padding: 0; + + cursor: pointer; + + accent-color: #2b8cff; +} + +#activityModal + .activity-terrain-control + output { + flex: 0 0 34px; + + min-width: 34px; + + margin: 0; + + font-size: 12px; + font-weight: 700; + + font-variant-numeric: tabular-nums; + + text-align: right; + white-space: nowrap; +} + +/* =============================== + PULSANTE INFO ATTIVITÀ + =============================== */ + +#activityInfoBtn { + position: fixed; + + top: calc(60px + env(safe-area-inset-top)); + right: calc(12px + env(safe-area-inset-right)); + + width: 44px; + height: 44px; + + padding: 0; + + display: inline-flex; + align-items: center; + justify-content: center; + + color: #0033aa; + background: #eef4ff; + + border: 1px solid #d0d8ff; + border-radius: 50%; + + box-shadow: + 0 2px 8px + rgb(0 0 0 / 35%); + + cursor: pointer; + + font-size: 21px; + line-height: 1; + + user-select: none; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; + + z-index: 13000; +} + +#activityInfoBtn:hover, +#activityInfoBtn.active { + background: #dfe9ff; + border-color: #9fb4ff; +} + +#activityInfoBtn:active { + transform: translateY(1px); +} + +#activityInfoBtn:focus-visible { + outline: 2px solid #4c9ffe; + outline-offset: 2px; +} + +/* =============================== + PULSANTE FOTO + =============================== */ + +#activityModal .activity-media-btn { + position: fixed; + + top: calc(112px + env(safe-area-inset-top)); + right: calc(12px + env(safe-area-inset-right)); + + z-index: 13000; + + min-width: 58px; + min-height: 38px; + + padding: 8px 12px; + + display: inline-flex; + align-items: center; + justify-content: center; + + border: 1px solid rgb(0 0 0 / 20%); + border-radius: 9px; + + background: rgb(255 255 255 / 96%); + color: #222; + + font-family: inherit; + font-size: 13px; + font-weight: 700; + line-height: 1; + + box-shadow: + 0 2px 8px + rgb(0 0 0 / 30%); + + cursor: pointer; + + user-select: none; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; +} + +#activityModal .activity-media-btn:hover { + background: #f1f5f9; +} + +#activityModal .activity-media-btn.active { + border-color: #2b8cff; + background: #2b8cff; + color: #fff; +} + +#activityModal .activity-media-btn:active { + transform: translateY(1px); +} + +#activityModal .activity-media-btn:focus-visible { + outline: 3px solid rgb(43 140 255 / 45%); + outline-offset: 2px; +} + +/* =============================== + INDICATORE CARICAMENTO TRACCIA + =============================== */ + +#activityModal .activity-route-loading { + position: absolute; + + right: calc(14px + env(safe-area-inset-right)); + bottom: calc(120px + env(safe-area-inset-bottom)); + + z-index: 13000; + + display: flex; + align-items: center; + gap: 8px; + + max-width: calc(100% - 28px); + + padding: 8px 12px; + + border-radius: 999px; + + background: rgb(20 30 42 / 88%); + color: #fff; + + font-size: 12px; + font-weight: 600; + line-height: 1.2; + + box-shadow: + 0 2px 10px + rgb(0 0 0 / 25%); + + pointer-events: none; +} + +#activityModal .activity-route-loading.is-error { + background: rgb(95 25 30 / 92%); +} + +#activityModal .activity-route-loading-spinner { + width: 15px; + height: 15px; + + flex: 0 0 auto; + + border: 2px solid rgb(255 255 255 / 25%); + border-top-color: #fff; + border-radius: 50%; + + animation: + activity-route-spin + 0.8s linear infinite; +} + +@keyframes activity-route-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + #activityModal .activity-route-loading-spinner { + animation: none; + } +} + +/* =============================== + MARKER MEDIA MAPBOX + =============================== */ + +#activityModal .activity-media-mapbox-marker { + position: relative; + + width: 46px; + height: 46px; + + padding: 0; + + border: 2px solid #fff; + border-radius: 7px; + + overflow: hidden; + + background: #fff; + + box-shadow: + 0 2px 8px + rgb(0 0 0 / 35%); + + cursor: pointer; + + -webkit-tap-highlight-color: transparent; +} + +#activityModal .activity-media-mapbox-marker img { + display: block; + + width: 100%; + height: 100%; + + max-width: none; + + object-fit: cover; +} + +#activityModal .activity-media-mapbox-marker:focus-visible { + outline: 3px solid rgb(43 140 255 / 60%); + outline-offset: 2px; +} + +#activityModal .activity-media-video-icon { + position: absolute; + inset: 0; + + display: flex; + align-items: center; + justify-content: center; + + color: #fff; + + font-size: 18px; + + background: rgb(0 0 0 / 25%); + + text-shadow: + 0 1px 3px + rgb(0 0 0 / 80%); + + pointer-events: none; +} + +/* =============================== + MARKER MEDIA LEAFLET + =============================== */ + +#activityModal .activity-media-marker, +#activityModal .photo-marker { + border-radius: 7px; + + box-shadow: + 0 2px 8px + rgb(0 0 0 / 35%); +} + +/* =============================== + INFORMAZIONI ATTIVITÀ + =============================== */ + +#activityModal .activity-modal-info { + position: absolute; + + left: max(12px, env(safe-area-inset-left)); + right: max(12px, env(safe-area-inset-right)); + bottom: max(12px, env(safe-area-inset-bottom)); + + padding: 12px 16px; + + color: #fff; + background: rgb(0 0 0 / 76%); + + border: 1px solid rgb(255 255 255 / 18%); + border-radius: 12px; + + box-shadow: + 0 4px 16px + rgb(0 0 0 / 35%); + + pointer-events: none; + + z-index: 12500; +} + +#activityModal .activity-modal-title { + margin: 0 0 4px; + + font-size: 16px; + font-weight: 700; + line-height: 1.25; +} + +#activityModal .activity-modal-meta { + margin-top: 2px; + + font-size: 14px; + line-height: 1.35; + + opacity: 0.95; +} + +/* =============================== + PULSANTE CHIUSURA + =============================== */ + +#activityModalClose.modal-close { + z-index: 16000 !important; +} + +/* =============================== + INFO PANEL SOPRA LA MAPPA + =============================== */ + +#infoPanel.open, +#infoPanel[data-open="1"] { + z-index: 21000 !important; +} + +/* =============================== + VISUALIZZAZIONE MOBILE + =============================== */ + +@media (max-width: 768px) { + /* Informazioni attività */ + #activityModal .activity-modal-info { + left: max(8px, env(safe-area-inset-left)); + right: max(8px, env(safe-area-inset-right)); + bottom: max(8px, env(safe-area-inset-bottom)); + + padding: 10px 12px; + } + + #activityModal .activity-modal-title { + font-size: 15px; + } + + #activityModal .activity-modal-meta { + font-size: 13px; + } + + /* Pulsante informazioni */ + #activityInfoBtn { + top: calc(58px + env(safe-area-inset-top)); + right: calc(10px + env(safe-area-inset-right)); + } + + /* Pulsante Foto */ + #activityModal .activity-media-btn { + top: calc(110px + env(safe-area-inset-top)); + right: calc(10px + env(safe-area-inset-right)); + } + + /* Indicatore caricamento traccia completa */ + #activityModal .activity-route-loading { + right: calc(8px + env(safe-area-inset-right)); + bottom: calc(112px + env(safe-area-inset-bottom)); + + max-width: calc( + 100% - + 16px - + env(safe-area-inset-left) - + env(safe-area-inset-right) + ); + + font-size: 11px; + } + + /* + * Controllo Altezza Terrain 3D. + * Si trova a destra dei controlli + * zoom e bussola di Mapbox. + */ + #activityModal .activity-terrain-control { + top: calc(8px + env(safe-area-inset-top)); + left: calc(60px + env(safe-area-inset-left)); + + width: min( + 190px, + calc( + 100vw - + 190px - + env(safe-area-inset-left) - + env(safe-area-inset-right) + ) + ); + + height: 44px; + + gap: 6px; + padding: 0 8px; + + font-size: 11px; + } + + #activityModal .activity-terrain-control label { + flex: 0 0 auto; + + margin: 0; + + white-space: nowrap; + } + + #activityModal .activity-terrain-range { + flex: 1 1 auto; + + min-width: 45px; + height: 18px; + + margin: 0; + } + + #activityModal + .activity-terrain-control + output { + flex: 0 0 30px; + + min-width: 30px; + + font-size: 10px; + } + + /* + * Pulsante Layers ufficiale di Leaflet. + * È posizionato a sinistra della chiusura. + */ + #activityModal + .activity-map-style-control.leaflet-control-layers-toggle { + position: fixed !important; + + top: calc( + 8px + + env(safe-area-inset-top) + ) !important; + + right: calc( + 68px + + env(safe-area-inset-right) + ) !important; + + left: auto !important; + + z-index: 15000 !important; + + box-sizing: border-box !important; + + width: 44px !important; + min-width: 44px !important; + max-width: 44px !important; + + height: 44px !important; + min-height: 44px !important; + max-height: 44px !important; + + display: block !important; + + margin: 0 !important; + padding: 0 !important; + + overflow: hidden !important; + + border: + 2px solid + rgb(0 0 0 / 25%) !important; + + border-radius: 9px !important; + + background-color: + rgb(255 255 255 / 98%) !important; + + background-image: + url("https://unpkg.com/leaflet@1.9.4/dist/images/layers.png") + !important; + + background-position: + center !important; + + background-repeat: + no-repeat !important; + + background-size: + 26px 26px !important; + + box-shadow: + 0 2px 8px + rgb(0 0 0 / 35%) !important; + + cursor: pointer !important; + } + + #activityModal + .activity-map-style-control.leaflet-control-layers-toggle:hover { + background-color: #fff !important; + } + + #activityModal + .activity-map-style-control.leaflet-control-layers-toggle:active { + transform: translateY(1px); + } + + /* + * Disabilita eventuali vecchie icone + * create tramite pseudo-elementi. + */ + #activityModal + .activity-map-style-control.leaflet-control-layers-toggle::before, + #activityModal + .activity-map-style-control.leaflet-control-layers-toggle::after { + content: none !important; + display: none !important; + } + + /* + * Il select copre completamente il pulsante, + * ma resta trasparente e cliccabile. + */ + #activityModal + .activity-map-style-control + .activity-map-style-select { + position: absolute !important; + inset: 0 !important; + + z-index: 2 !important; + + width: 100% !important; + min-width: 0 !important; + max-width: none !important; + + height: 100% !important; + min-height: 0 !important; + max-height: none !important; + + display: block !important; + + margin: 0 !important; + padding: 0 !important; + + border: 0 !important; + border-radius: 0 !important; + + background: transparent !important; + + opacity: 0 !important; + + cursor: pointer !important; + + appearance: none !important; + -webkit-appearance: none !important; + } + + #activityModal + .activity-map-style-control:focus-within { + outline: + 3px solid + rgb(43 140 255 / 50%); + + outline-offset: 2px; + } +} diff --git a/public/css/admin.css b/public/css/admin.css new file mode 100644 index 0000000..a6b472a --- /dev/null +++ b/public/css/admin.css @@ -0,0 +1,70 @@ +/* =============================== + ADMIN — layout base + =============================== */ + +body { + font-family: Arial, sans-serif; + background: #fafafa; + margin: 0; + padding: 0; +} + +#app { + padding: 20px; +} + +h2 { + margin-bottom: 20px; + color: #333; +} + +/* =============================== + Pulsanti + =============================== */ + +button { + padding: 10px 14px; + margin: 5px 5px 5px 0; + border: none; + border-radius: 6px; + background: #1976d2; + color: white; + cursor: pointer; + font-size: 14px; +} + +button:hover { + background: #125a9c; +} + +/* =============================== + Box cambiamenti + =============================== */ + +#changesBox { + margin-top: 20px; + padding: 10px; + border: 1px solid #aaa; + border-radius: 5px; + background: #f7f7f7; + width: 350px; +} + +#changesBox h4 { + margin-top: 0; +} + +/* =============================== + Output + =============================== */ + +pre#out { + margin-top: 20px; + padding: 15px; + background: #eee; + border-radius: 6px; + max-height: 400px; + overflow: auto; + font-size: 14px; +} + diff --git a/public/css/base.css b/public/css/base.css new file mode 100644 index 0000000..2cbff47 --- /dev/null +++ b/public/css/base.css @@ -0,0 +1,55 @@ +:root { --header-h: 60px; } + +/* ========================================================= + MODALITÀ PHOTOS / ACTIVITIES (toggle globale) + ========================================================= */ + +body.photos { + --tile-bg: #ffffff; + --tile-border: transparent; + --tile-thumb-size: auto; + --tile-label-color: #000; + + --panel-bg: #ffffff; + --panel-title-color: #000; + + --modal-bg: #000000; +} + +body.activities { + --tile-bg: #f5f7ff; + --tile-border: #d0d8ff; + --tile-thumb-size: 64px; + --tile-label-color: #0033aa; + + --panel-bg: #eef4ff; + --panel-title-color: #0056d6; + + --modal-bg: #eef4ff; +} + +/* Safe-area iOS */ +@supports (top: env(safe-area-inset-top)) { + :root { --safe-top: env(safe-area-inset-top); } +} +@supports not (top: env(safe-area-inset-top)) { + :root { --safe-top: 0px; } +} + +body { + font-family: sans-serif; + margin: 0; + padding: 0; + background: #fafafa; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 4px; +} diff --git a/public/css/bottomSheet.css b/public/css/bottomSheet.css new file mode 100644 index 0000000..514a76b --- /dev/null +++ b/public/css/bottomSheet.css @@ -0,0 +1,348 @@ +/* ========================================= + Variabili globali + ========================================= */ +:root { + --header-height: 60px; /* cambia se il tuo header è più alto/basso */ +} + +/* ========================================= + MAPPA GLOBALE (contenitore sotto l’header) + ========================================= */ +.global-map { + position: fixed; + top: var(--header-height); + left: 0; + right: 0; + bottom: 0; + + width: 100%; + display: none; /* visibile solo con .open */ + z-index: 10; /* sotto a bottom-sheet (9999) e modal (10000) */ + background: #000; /* evita flash bianco durante init */ +} + +.global-map.open { + display: block; +} + +/* Leaflet riempie il contenitore */ +.global-map .leaflet-container { + width: 100%; + height: 100%; +} + +/* Marker immagine (miniatura) */ +.leaflet-marker-icon.photo-marker { + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.25); + border: 2px solid rgba(255,255,255,0.9); + background: #fff; +} + +/* Nascondi la gallery quando la mappa è aperta */ +.gallery.hidden { + display: none !important; +} + +/* ========================================= + BOTTOM SHEET — struttura base comune + ========================================= */ +.bottom-sheet { + position: fixed; + bottom: 0; + left: 0; + width: 100%; + + background: rgba(255,255,255,0.95); + backdrop-filter: blur(6px); + border-top: 1px solid #ddd; + box-shadow: 0 -2px 10px rgba(0,0,0,0.15); + + display: none; /* diventa flex con .open */ + flex-direction: column; + z-index: 9999; /* molto alto: il modal starà sopra (10000) */ +} + +/* ========================================================= + MODALITÀ ACTIVITIES — override minimale + ========================================================= */ + +/* Strip attività */ +body.activities .photo-strip { + height: 120px; /* più compatta */ + background: var(--panel-bg); +} + +/* Contenitore elementi */ +body.activities #sheetGallery, +body.activities .sheet-gallery { + gap: 8px; + padding: 10px; + background: var(--panel-bg); +} + +/* Tile attività */ +body.activities .sheet-item { + width: 80px; + height: 80px; + border-radius: 10px; + background: var(--tile-bg); + border: 1px solid var(--tile-border); + box-shadow: none; + display: flex; + align-items: center; + justify-content: center; +} + +/* Icona/mappa attività */ +body.activities .sheet-item img { + width: var(--tile-thumb-size); + height: var(--tile-thumb-size); + object-fit: contain; + filter: grayscale(40%); + padding: 8px; +} + + + + + +/* _-----------------*/ +.activity-sheet-route { + width: 80px; + height: 80px; + overflow: hidden; +} + +.activity-sheet-route svg { + width: 100%; + height: 100%; +} + + +/* Nascondi miniature foto */ +body.activities .sheet-thumb { + display: none !important; +} + +/* Nascondi layout foto */ +body.activities .sheet-item img[style*="object-fit: cover"] { + object-fit: contain !important; +} + +/* Header della sheet più coerente */ +body.activities .sheet-header::before { + background: #007bff; +} + +/* Opzioni sheet coerenti */ +body.activities #optionsSheet h3 { + color: var(--panel-title-color); +} + +body.activities .sheet-btn { + background: #eef4ff; + border: 1px solid #d0d8ff; + color: #0033aa; +} + +body.activities .sheet-btn:hover { + background: #e0eaff; +} +.bottom-sheet.open { + display: flex; +} + +/* Maniglia superiore */ +.sheet-header { + height: 16px; + display: flex; + justify-content: center; + align-items: center; +} + +.sheet-header::before { + content: ""; + width: 40px; + height: 4px; + background: #bbb; + border-radius: 4px; +} + +/* ========================================= + BOTTOM SHEET FOTO (strip bassa come nel vecchio) + ========================================= */ +.photo-strip { + height: 140px; /* altezza originale della strip */ + overflow-y: hidden; /* niente scroll verticale */ + overflow-x: auto; /* scroll orizzontale per le foto */ +} + +/* Contenitore elementi della strip — compatibile con id e class */ +#sheetGallery, +.sheet-gallery { + display: flex; + flex-direction: row; + overflow-x: auto; + padding: 10px; + gap: 10px; + + -webkit-overflow-scrolling: touch; + scroll-snap-type: x proximity; +} + +/* Singolo elemento della strip */ +.sheet-item { + width: 90px; + height: 90px; + border-radius: 10px; + overflow: hidden; + flex-shrink: 0; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + background: #eee; + scroll-snap-align: start; +} + +/* Miniatura della foto nella strip */ +.sheet-thumb, +.sheet-item img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + border-radius: 8px; /* alias; la .sheet-item ha già 10px */ +} + +/* ========================================= + BOTTOM SHEET OPZIONI (⋮) — menu grande + ========================================= */ +.options-sheet { + height: auto; + max-height: 80vh; + overflow-y: auto; +} + +.sheet-content { + padding: 20px; +} + +.sheet-btn { + width: 100%; + padding: 12px; + margin-bottom: 8px; + text-align: left; + background: #f5f5f5; + border: none; + border-radius: 8px; + font-size: 15px; + cursor: pointer; +} + +.sheet-btn:hover { + background: #e8e8e8; +} + +#optionsSheet h3 { + margin-top: 20px; + margin-bottom: 10px; + font-size: 16px; + color: #444; +} + +/* ========================================= + OVERLAY per chiusura sheet/option + ========================================= */ +.sheet-overlay { + position: fixed; + inset: 0; + background: transparent; + display: none; + z-index: 80; + pointer-events: none; +} + +.sheet-overlay.open { + display: block; + pointer-events: none; +} + +.bottom-sheet { + pointer-events: auto; +} + +/* ========================================= + MODAL sopra allo sheet + ========================================= */ +.modal.open { + z-index: 10000 !important; /* sopra al bottom sheet (9999) */ +} + +/* ========================================= + Piccoli affinamenti facoltativi + ========================================= */ +/* scrollbar sottile solo per la strip (opzionale) */ +#sheetGallery::-webkit-scrollbar, +.sheet-gallery::-webkit-scrollbar { + height: 8px; +} +#sheetGallery::-webkit-scrollbar-thumb, +.sheet-gallery::-webkit-scrollbar-thumb { + background: rgba(0,0,0,0.25); + border-radius: 4px; +} + + +.sheet-overlay { + background: transparent !important; + pointer-events: none !important; +} + +.sheet-overlay.open { + background: transparent !important; + pointer-events: none !important; +} + + + + +/* =============================== + THUMB TRACCIA ATTIVITÀ BOTTOM SHEET + =============================== */ + +.activity-sheet-item { + width: 110px; + height: 110px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 4px; +} + +.activity-sheet-route { + width: 90px; + height: 70px; + flex-shrink: 0; + overflow: hidden; +} + +.activity-sheet-route svg.activity-route-svg { + width: 100%; + height: 100%; + display: block; +} + +.activity-sheet-label { + margin-top: 4px; + width: 100%; + font-size: 11px; + text-align: center; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.activity-sheet-route .activity-route-placeholder { + font-size: 10px; + text-align: center; +} diff --git a/public/css/faces.css b/public/css/faces.css new file mode 100644 index 0000000..825ef16 --- /dev/null +++ b/public/css/faces.css @@ -0,0 +1,663 @@ +:root { + color-scheme: light; + font-family: + Inter, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + + --background: #f3f5f8; + --surface: #ffffff; + --surface-muted: #f8fafc; + --border: #dfe4ea; + --text: #172033; + --muted: #667085; + --primary: #2563eb; + --primary-hover: #1d4ed8; + --danger: #b42318; + --success: #067647; + --shadow: + 0 8px 24px rgba(15, 23, 42, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--background); + color: var(--text); +} + +button { + font: inherit; +} + +.hidden { + display: none !important; +} + +.auth-loader { + display: grid; + min-height: 100vh; + place-items: center; + color: var(--muted); +} + +.faces-app { + width: min(1440px, 100%); + min-height: 100vh; + margin: 0 auto; + padding: 24px; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 20px; +} + +.eyebrow { + margin: 0 0 4px; + color: var(--primary); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 8px; + font-size: clamp(1.9rem, 4vw, 2.8rem); +} + +.page-subtitle, +.section-heading p, +.cluster-metadata, +.face-details, +.empty-state { + color: var(--muted); +} + +.page-subtitle { + margin-bottom: 0; +} + +.header-actions { + display: flex; + gap: 10px; +} + +.primary-button, +.secondary-button { + min-height: 42px; + padding: 9px 16px; + border-radius: 12px; + cursor: pointer; + font-weight: 650; +} + +.primary-button { + border: 1px solid var(--primary); + background: var(--primary); + color: white; +} + +.primary-button:hover { + background: var(--primary-hover); +} + +.secondary-button { + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); +} + +.status-panel { + margin-bottom: 18px; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); +} + +.status-panel.error { + border-color: #fecdca; + background: #fef3f2; + color: var(--danger); +} + +.status-panel.success { + border-color: #abefc6; + background: #ecfdf3; + color: var(--success); +} + +.review-tabs { + display: flex; + gap: 8px; + margin-bottom: 22px; + overflow-x: auto; +} + +.review-tab { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px 15px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--text); + cursor: pointer; + white-space: nowrap; +} + +.review-tab span { + display: inline-grid; + min-width: 24px; + min-height: 24px; + place-items: center; + border-radius: 999px; + background: var(--surface-muted); + font-size: 0.8rem; + font-weight: 700; +} + +.review-tab.active { + border-color: var(--primary); + background: var(--primary); + color: white; +} + +.review-tab.active span { + background: rgba(255, 255, 255, 0.18); +} + +.section-heading { + display: flex; + justify-content: space-between; + margin-bottom: 16px; +} + +.section-heading h2 { + margin-bottom: 5px; +} + +.section-heading p { + margin-bottom: 0; +} + +.cluster-grid { + display: grid; + gap: 18px; +} + +.cluster-card { + padding: 18px; + border: 1px solid var(--border); + border-radius: 20px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.cluster-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.cluster-title { + margin-bottom: 4px; + font-size: 1.2rem; +} + +.cluster-size-badge { + padding: 5px 10px; + border-radius: 999px; + background: #eff6ff; + color: #1d4ed8; + font-size: 0.82rem; + font-weight: 700; +} + +.cluster-metadata { + margin-bottom: 0; + font-size: 0.9rem; +} + +.face-grid { + display: grid; + grid-template-columns: + repeat( + auto-fill, + minmax(150px, 1fr) + ); + gap: 14px; +} + +.face-card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface-muted); +} + +.face-image-wrapper { + position: relative; + aspect-ratio: 1; + overflow: hidden; + background: #e8edf3; +} + +.face-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.face-image.loading { + opacity: 0.25; +} + +.face-placeholder { + display: grid; + width: 100%; + height: 100%; + place-items: center; + padding: 12px; + color: var(--muted); + text-align: center; +} + +.face-body { + padding: 10px; +} + +.face-name { + overflow: hidden; + margin-bottom: 5px; + font-size: 0.88rem; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.face-details { + margin-bottom: 0; + font-size: 0.78rem; + line-height: 1.45; +} + +.persons-grid { + display: grid; + grid-template-columns: + repeat( + auto-fill, + minmax(230px, 1fr) + ); + gap: 14px; +} + +.person-card, +.empty-state { + padding: 18px; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface); +} + +.empty-state { + text-align: center; +} + +@media (max-width: 720px) { + .faces-app { + padding: 16px; + } + + .page-header { + display: block; + } + + .header-actions { + margin-top: 16px; + } + + .header-actions button { + flex: 1; + } + + .cluster-header { + display: block; + } + + .cluster-size-badge { + display: inline-block; + margin-top: 8px; + } + + .face-grid { + grid-template-columns: + repeat( + auto-fill, + minmax(125px, 1fr) + ); + } +} + +.cluster-controls { + display: grid; + grid-template-columns: + minmax(180px, 1fr) + minmax(180px, 1fr) + auto; + gap: 10px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.person-name-input, +.person-select { + width: 100%; + min-height: 42px; + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + color: var(--text); + font: inherit; +} + +.face-card.selectable { + position: relative; +} + +.face-selection { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + background: var(--surface); + cursor: pointer; + font-size: 0.82rem; + font-weight: 700; +} + +.face-selection input { + width: 18px; + height: 18px; +} + +@media (max-width: 720px) { + .cluster-controls { + grid-template-columns: 1fr; + } +} + +.person-card.clickable { + cursor: pointer; + transition: + transform 150ms ease, + box-shadow 150ms ease; +} + +.person-card.clickable:hover, +.person-card.clickable:focus { + transform: translateY(-2px); + box-shadow: var(--shadow); + outline: none; +} + +.person-detail-overlay { + position: fixed; + z-index: 1000; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgba(15, 23, 42, 0.72); +} + +.person-detail-dialog { + width: min(1200px, 100%); + max-height: 92vh; + overflow: auto; + padding: 20px; + border-radius: 22px; + background: var(--surface); + box-shadow: + 0 24px 60px rgba(15, 23, 42, 0.3); +} + +.person-detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 18px; +} + +.person-rename-area { + display: grid; + grid-template-columns: + minmax(220px, 1fr) + auto; + gap: 10px; + margin-bottom: 20px; +} + +.person-detail-faces { + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.danger-button { + width: 100%; + margin-top: 10px; + padding: 8px 10px; + border: 1px solid #fda29b; + border-radius: 10px; + background: #fef3f2; + color: var(--danger); + cursor: pointer; + font: inherit; + font-size: 0.78rem; + font-weight: 700; +} + +.danger-button:hover { + background: #fee4e2; +} + +.danger-button:disabled { + cursor: wait; + opacity: 0.6; +} + +@media (max-width: 720px) { + .person-detail-overlay { + padding: 8px; + } + + .person-detail-dialog { + max-height: 96vh; + padding: 15px; + border-radius: 16px; + } + + .person-detail-header { + display: block; + } + + .person-detail-header button { + width: 100%; + margin-top: 12px; + } + + .person-rename-area { + grid-template-columns: 1fr; + } +} + +[hidden] { + display: none !important; +} + +.noise-person-controls { + display: grid; + gap: 8px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--border); +} + +.noise-person-controls .primary-button { + width: 100%; +} + +.noise-person-controls .primary-button:disabled { + cursor: wait; + opacity: 0.65; +} + +.bulk-noise-controls { + display: grid; + grid-template-columns: + auto + minmax(180px, 1fr) + minmax(180px, 1fr) + auto; + gap: 10px; + align-items: center; + margin-bottom: 18px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.bulk-selection-actions { + display: flex; + gap: 8px; +} + +.noise-selectable { + position: relative; +} + +.noise-selection { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 10px; + border-bottom: 1px solid var(--border); + background: var(--surface); + cursor: pointer; + font-size: 0.84rem; + font-weight: 700; +} + +.noise-selection input { + width: 19px; + height: 19px; +} + +@media (max-width: 900px) { + .bulk-noise-controls { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 620px) { + .bulk-noise-controls { + grid-template-columns: 1fr; + } + + .bulk-selection-actions { + display: grid; + grid-template-columns: 1fr 1fr; + } +} + + +.person-face-badges { + position: absolute; + z-index: 3; + top: 8px; + left: 8px; + right: 8px; + display: flex; + flex-wrap: wrap; + gap: 5px; + pointer-events: none; +} +.person-face-badge { + padding: 4px 7px; + border-radius: 999px; + box-shadow: 0 2px 7px rgba(15, 23, 42, 0.18); + font-size: 0.68rem; + font-weight: 800; +} +.prototype-badge { background: #dcfae6; color: #067647; } +.difficult-badge { background: #fef0c7; color: #b54708; } +.noise-badge { background: #f4ebff; color: #6941c6; } +.cluster-badge { background: #eff8ff; color: #175cd3; } +.reference-info { margin-top: 8px; padding: 8px; border-radius: 10px; font-size: 0.76rem; line-height: 1.4; } +.prototype-info { background: #ecfdf3; color: #067647; } +.difficult-info { background: #fffaeb; color: #b54708; } +.recognition-index-panel { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-muted); } +.index-status { padding: 4px 9px; border-radius: 999px; font-size: 0.76rem; font-weight: 800; } +.index-status.dirty { background: #fef0c7; color: #b54708; } +.index-status.ready { background: #dcfae6; color: #067647; } +.index-state-details { color: var(--muted); font-size: 0.82rem; } +.person-face-filters { display: flex; gap: 8px; margin-bottom: 16px; overflow-x: auto; } +.person-face-filter { flex: 0 0 auto; min-height: 36px; padding: 7px 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--text); cursor: pointer; font: inherit; font-size: 0.8rem; font-weight: 700; } +.person-face-filter.active { border-color: var(--primary); background: var(--primary); color: white; } + +.recognized-badge { + background: #dcfae6; + color: #067647; +} + +.below-threshold-badge { + background: #fef0c7; + color: #b54708; +} + +.recognition-coverage-summary { + margin: 5px 0 0; + color: #067647; + font-size: 0.82rem; + font-weight: 750; +} + +.person-face-filter[data-filter="below"] { + border-color: #fdb022; +} + +.person-face-filter[data-filter="below"].active { + border-color: #f79009; + background: #f79009; + color: #ffffff; +} diff --git a/public/css/gallery.css b/public/css/gallery.css new file mode 100644 index 0000000..c1501f5 --- /dev/null +++ b/public/css/gallery.css @@ -0,0 +1,340 @@ +.gallery { + display: block; + padding: 6px; +} + +.gallery-section-title { + font-size: 18px; + font-weight: 600; + margin: 18px 6px 6px; + color: #444; +} + +.gallery-section { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 4px; + padding: 0 4px; +} + +/* =============================== + FOTO / VIDEO THUMBS + =============================== */ + +.thumb { + width: 100%; + aspect-ratio: 1 / 1; + border-radius: 6px; + overflow: hidden; + background: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + position: relative; + cursor: pointer; +} + +.thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.thumb-filename { + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 25; + box-sizing: border-box; + padding: 4px 5px; + background: rgba(0, 0, 0, 0.78); + color: #fff; + font-size: 10px; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} + +.play-icon { + position: absolute; + bottom: 4px; + right: 4px; + background: rgba(0, 0, 0, 0.55); + color: white; + padding: 2px 4px; + border-radius: 4px; + font-size: 11px; +} + +/* =============================== + SOFT DELETE — FOTO + ATTIVITÀ + =============================== */ + +.soft-deleted { + position: relative; + opacity: 0.55; +} + +.soft-deleted::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 70%; + height: 3px; + background: rgba(255, 0, 0, 0.85); + transform: translate(-50%, -50%) rotate(45deg); + pointer-events: none; + z-index: 20; +} + +.soft-deleted::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 70%; + height: 3px; + background: rgba(255, 0, 0, 0.85); + transform: translate(-50%, -50%) rotate(-45deg); + pointer-events: none; + z-index: 20; +} + +/* =============================== + MODALITÀ ACTIVITIES + =============================== */ + +body.activities .gallery-section-title { + color: #0033aa; +} + +/* Tile attività */ +.activity-tile { + width: 100%; + aspect-ratio: 1 / 1; + border-radius: 8px; + overflow: hidden; + background: #111827; + position: relative; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.16); + display: flex; + flex-direction: column; +} + +/* Area percorso */ +.activity-route-thumb { + width: 100%; + flex: 1 1 auto; + min-height: 0; + background: #0f172a; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.activity-route-thumb.loading { + background: linear-gradient(135deg, #111827, #1f2937); +} + +.activity-route-thumb.empty { + background: linear-gradient(135deg, #111827, #1f2937); +} + +/* Placeholder caricamento / fallback */ +.activity-route-placeholder { + color: rgba(255, 255, 255, 0.72); + font-size: 11px; + line-height: 1.2; + text-align: center; + padding: 8px; +} + +/* SVG percorso */ +.activity-route-svg { + width: 100%; + height: 100%; + display: block; +} + +.activity-route-bg { + fill: #0f172a; +} + +.activity-route-line-shadow { + fill: none; + stroke: rgba(0, 0, 0, 0.5); + stroke-width: 7; + stroke-linecap: round; + stroke-linejoin: round; +} + +.activity-route-line { + fill: none; + stroke: #38bdf8; + stroke-width: 4; + stroke-linecap: round; + stroke-linejoin: round; +} + +.activity-route-start { + fill: #22c55e; + stroke: white; + stroke-width: 1.6; +} + +.activity-route-end { + fill: #ef4444; + stroke: white; + stroke-width: 1.6; +} + +/* Label attività */ +.activity-label { + flex: 0 0 auto; + padding: 6px 7px 1px; + font-size: 12px; + line-height: 1.15; + color: white; + background: rgba(0, 0, 0, 0.52); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Metadati attività */ +.activity-meta { + flex: 0 0 auto; + padding: 0 7px 6px; + font-size: 10.5px; + line-height: 1.15; + color: rgba(255, 255, 255, 0.78); + background: rgba(0, 0, 0, 0.52); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Icona fallback, se usata */ +.activity-icon { + width: 48px; + height: 48px; + object-fit: contain; + opacity: 0.9; +} + +/* Niente play-icon per attività */ +body.activities .play-icon { + display: none; +} + +/* =============================== + RESPONSIVE / THUMB ATTIVITÀ PIÙ LEGGIBILI + =============================== */ + +body.activities .gallery-section { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 6px; +} + +/* Su schermi più larghi, attività un po' più grandi */ +@media (min-width: 700px) { + body.activities .gallery-section { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + } +} + +/* Su schermi molto piccoli, resta compatto */ +@media (max-width: 420px) { + .gallery-section { + grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); + } + + body.activities .gallery-section { + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + } + + .activity-label { + font-size: 11.5px; + } + + .activity-meta { + font-size: 10px; + } +} + +/* =============================== + TIMELINE VIRTUALIZZATA + =============================== */ + +.gallery-section.gallery-virtualized { + display: block; + position: relative; + box-sizing: border-box; + width: 100%; + padding: 0; +} + +.gallery-virtual-window { + position: absolute; + top: 0; + left: 4px; + right: 4px; + display: grid; + align-content: start; + box-sizing: border-box; +} + +.gallery-virtual-window > .thumb, +.gallery-virtual-window > .activity-tile { + min-width: 0; + width: 100%; + box-sizing: border-box; +} + + +/* Evidenzia le miniature selezionate, anche quando ricreate dal renderer */ +.thumb.selection-selected, +.activity-tile.selection-selected { + outline: 3px solid #1687ff; + outline-offset: -3px; +} + +.thumb.selection-selected::after, +.activity-tile.selection-selected::after { + content: "✓"; + position: absolute; + top: 5px; + right: 5px; + z-index: 30; + width: 23px; + height: 23px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: #1687ff; + color: #fff; + font-size: 15px; + font-weight: 700; + box-shadow: 0 1px 4px rgba(0,0,0,.35); + pointer-events: none; +} + +/* Evita il menu contestuale e la selezione nativa sulle miniature */ +.thumb, +.activity-tile, +.thumb img { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + -webkit-user-drag: none; + touch-action: pan-y; +} diff --git a/public/css/header.css b/public/css/header.css new file mode 100644 index 0000000..9cd3c76 --- /dev/null +++ b/public/css/header.css @@ -0,0 +1,436 @@ +/* =============================== + Header compatto + =============================== */ +header { + padding: 4px 10px; /* era 10px 15px */ + background: #333; + color: white; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 100; +} + +/* Titolo più piccolo e senza margini extra */ +header h1 { + font-size: 18px; /* ridotto */ + line-height: 1.1; + margin: 0; +} + +/* Contenitore bottoni in alto a destra */ +.top-buttons { + display: flex; + gap: 6px; /* era 10px */ +} + +/* Bottoni icona più compatti */ +.icon-btn { + background: none; + border: none; + font-size: 18px; /* era 22px */ + padding: 3px 6px; /* era 6px 10px */ + cursor: pointer; + border-radius: 6px; + color: white; + line-height: 1; + min-height: 32px; /* tap target minimo desktop */ + min-width: 32px; +} + +.icon-btn:hover { + background: rgba(255,255,255,0.15); +} + +/* Logout rotondo: riduciamo la “bolla” */ +.icon-btn.logout-btn { + --size: 28px; /* era 36px */ + width: var(--size); + height: var(--size); + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* PNG del logout in scala con l’header */ +.logout-icon { + width: 18px; /* era 22px */ + height: 18px; + display: block; + filter: brightness(0) invert(1); + image-rendering: -webkit-optimize-contrast; +} + +/* =============================== + Visibilità Logout robusta + =============================== */ + +/* Base: nascosto (prima del login o se non autenticato) */ +#logoutBtn { + display: none; +} + +/* Quando autenticato, mostra il bottone coerente con gli altri icon-btn */ +body.authenticated #logoutBtn { + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* =============================== + Toggle Photos / Activities + =============================== */ + +.mode-toggle { + display: flex; + gap: 8px; + margin-left: 20px; +} + +.mode-toggle button { + padding: 6px 12px; + border: 1px solid #ccc; + background: #f5f5f5; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + color: #333; +} + +.mode-toggle button.active { + background: #007bff; + color: white; + border-color: #007bff; +} + +.mode-toggle { + background: transparent; + color: #fff; + + border: 1px solid #fff; + border-radius: 10px; + + padding: 8px 10px; + + font-size: 14px; + font-weight: 600; + + cursor: pointer; + + display: flex; + align-items: center; + gap: 6px; + + transition: all 0.2s ease; +} + +.mode-toggle:hover { + background: #111; +} + +.mode-toggle:active { + transform: scale(0.96); +} + + +/* Barra superiore in modalità selezione */ +.selection-bar { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; + margin-left: 8px; + color: #fff; + font-size: 14px; +} + +.selection-bar[hidden], +.selection-actions-menu[hidden] { + display: none !important; +} + +.selection-bar #selectionCount { + white-space: nowrap; +} + +.selection-actions-wrap { + position: relative; +} + +.selection-actions-menu { + position: absolute; + z-index: 500; + top: calc(100% + 8px); + right: 0; + width: min(300px, calc(100vw - 24px)); + padding: 6px; + border: 1px solid rgba(0, 0, 0, .08); + border-radius: 12px; + background: #fff; + color: #222; + box-shadow: 0 8px 28px rgba(0, 0, 0, .24); + font-size: 14px; + line-height: 1.4; + transform-origin: top right; +} + +.selection-menu-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 48px; + padding: 10px 12px; + border: 0; + border-radius: 8px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + touch-action: manipulation; +} + +.selection-menu-item + .selection-menu-item { + border-top: 1px solid #eee; + border-radius: 0 0 8px 8px; +} + +.selection-menu-item:hover, +.selection-menu-item:focus-visible { + background: #f1f3f5; + outline: none; +} + +.selection-menu-item:active { + background: #e7e9ec; +} + +.selection-menu-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + flex: 0 0 24px; + font-size: 18px; +} + +.selection-menu-label { + flex: 1; + font-weight: 500; +} + +.selection-menu-hint { + color: #777; + font-size: 11px; + white-space: nowrap; +} + +@media (hover: none) { + .selection-menu-item:hover { + background: transparent; + } +} + +body.selection-mode header > h1, +body.selection-mode #modeToggle { + display: none !important; +} + +body.selection-mode .top-buttons > :not(#logoutBtn) { + display: none !important; +} + +body.selection-mode .topbar { + gap: 6px; +} + +body.selection-mode .selection-bar { + display: flex; +} + +body.selection-mode .selection-bar[hidden] { + display: none !important; +} +/* Android-like contextual menu */ + +/* Android-like contextual menu */ +.selection-actions-menu { + box-sizing: border-box; + width: min(280px, calc(100vw - 24px)); + max-width: calc(100vw - 24px); + max-height: calc(100dvh - 24px); + overflow-y: auto; + overscroll-behavior: contain; + padding: 6px; + border: 1px solid rgba(127, 127, 127, .16); + border-radius: 16px; + background: #fff; + color: #202124; + box-shadow: + 0 2px 6px rgba(0, 0, 0, .12), + 0 8px 24px rgba(0, 0, 0, .18); + font-size: 14px; + line-height: 1.4; + transform-origin: top right; + animation: selection-menu-in 130ms ease-out; + -webkit-tap-highlight-color: transparent; +} + +.selection-actions-menu.opens-up { + top: auto; + bottom: calc(100% + 8px); + transform-origin: bottom right; +} + +.selection-menu-item, +.selection-menu-item + .selection-menu-item { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 48px; + padding: 12px 14px; + border: 0; + border-radius: 10px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + touch-action: manipulation; +} + +.selection-menu-item + .selection-menu-item { + margin-top: 2px; +} + +.selection-menu-item:hover, +.selection-menu-item:focus-visible { + background: rgba(60, 64, 67, .08); + outline: none; +} + +.selection-menu-item:active { + background: rgba(60, 64, 67, .14); +} + +.selection-menu-icon { + width: 24px; + height: 24px; + flex: 0 0 24px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 19px; +} + +.selection-menu-label { + flex: 1; + font-weight: 500; +} + +.selection-menu-hint { + color: #70757a; + font-size: 12px; + white-space: nowrap; +} + +@keyframes selection-menu-in { + from { + opacity: 0; + transform: scale(.96) translateY(-3px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* Desktop: apri il menu verso destra */ +@media (min-width: 601px) { + .selection-actions-menu { + left: 0; + right: auto; + transform-origin: top left; + } + + .selection-actions-menu.opens-up { + transform-origin: bottom left; + } +} + +@media (prefers-color-scheme: dark) { + .selection-actions-menu { + background: #2b2b2b; + color: #f1f1f1; + border-color: rgba(255, 255, 255, .10); + box-shadow: + 0 2px 6px rgba(0, 0, 0, .28), + 0 8px 24px rgba(0, 0, 0, .38); + } + + .selection-menu-item:hover, + .selection-menu-item:focus-visible { + background: rgba(255, 255, 255, .09); + } + + .selection-menu-item:active { + background: rgba(255, 255, 255, .15); + } + + .selection-menu-hint { + color: #bdbdbd; + } +} + +@media (prefers-reduced-motion: reduce) { + .selection-actions-menu { + animation: none; + } +} + + +/* Mobile centered Android-style popup */ +@media (max-width: 600px) { + .selection-actions-wrap { + position: static; + } + + .selection-actions-menu { + position: fixed; + top: 50%; + left: 50%; + right: auto; + bottom: auto; + width: min(320px, calc(100vw - 40px)); + max-width: calc(100vw - 40px); + max-height: min(70dvh, 420px); + transform: translate(-50%, -50%); + transform-origin: center; + z-index: 2000; + } + + .selection-actions-menu.opens-up { + top: 50%; + left: 50%; + right: auto; + bottom: auto; + } + + @keyframes selection-menu-in { + from { + opacity: 0; + transform: translate(-50%, -50%) scale(.96); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + } +} diff --git a/public/css/infoPanel.css b/public/css/infoPanel.css new file mode 100644 index 0000000..539291d --- /dev/null +++ b/public/css/infoPanel.css @@ -0,0 +1,140 @@ +/* =============================== + Variabili (scala tipografica pannello info) + Modifica qui per regolare tutto il pannello + =============================== */ +:root { + --info-font: 14px; /* base testo pannello (prima ~16px) */ + --info-line: 1.4; /* interlinea per migliorare leggibilità */ + + --info-heading: 15px; /* dimensione titoli h3 nel pannello */ + --info-h3-mt: 6px; /* margin-top h3 */ + --info-h3-mb: 10px; /* margin-bottom h3 */ + + --info-row-gap: 8px; /* spazio verticale tra righe (era 10px) */ + --info-label-w: 100px; /* larghezza colonna etichette (era 110px) */ + + --info-map-h: 220px; /* altezza mappa (era 250px) */ + --info-map-mt: 15px; /* spazio sopra la mappa */ + + --info-spacer-h: 16px; /* altezza degli spacer */ +} + +/* =============================== + PANNELLO INFO + =============================== */ +.info-panel { + position: fixed; + top: 0; + right: 0; + width: 320px; + height: 100%; + background: #fff; + padding: 16px; + box-shadow: -2px 0 6px rgba(0,0,0,0.25); + overflow-y: auto; + z-index: 21000; + transform: translateX(100%); + transition: transform 0.3s ease; + + /* Scala tipografica via variabili */ + font-size: var(--info-font); + line-height: var(--info-line); +} + +.info-panel.open { + transform: translateX(0); +} + +/* Titoli un filo più compatti */ +.info-panel h3 { + font-size: var(--info-heading); + margin: var(--info-h3-mt) 0 var(--info-h3-mb); +} + +/* Righe e label */ +.info-row { + margin-bottom: var(--info-row-gap); +} + +.info-row b { + display: inline-block; + width: var(--info-label-w); +} + +/* Mappa nel pannello */ +.info-map { + width: 100%; + height: var(--info-map-h); + margin-top: var(--info-map-mt); + border-radius: 6px; + overflow: hidden; + border: 1px solid #ccc; +} + +/* Spacer verticali */ +.info-spacer { + height: var(--info-spacer-h); +} + +/* =============================== + (Opzionale) Mobile: un filo più grande < 480px + Decommenta se vuoi mantenere leggibilità maggiore su schermi piccoli + =============================== */ +/* +@media (max-width: 480px) { + .info-panel { font-size: 15px; } + .info-panel h3 { font-size: 16px; } +} +*/ +/* ========================================================= + MODALITÀ ACTIVITIES — override minimale + ========================================================= */ + +/* Sfondo pannello attività */ +body.activities .info-panel { + background: var(--panel-bg); +} + +/* Titoli attività */ +body.activities .info-panel h3 { + color: var(--panel-title-color); +} + +/* Righe attività: più compatte e leggibili */ +body.activities .info-row { + margin-bottom: calc(var(--info-row-gap) - 2px); +} + +body.activities .info-row b { + color: #0033aa; + font-weight: 600; +} + +/* Mappa attività */ +body.activities .info-map { + border-color: #007bff; + background: #eef4ff; +} + +/* Spacers più leggeri */ +body.activities .info-spacer { + background: transparent; +} + + +.info-close-btn { + position: absolute; + top: 10px; + right: 10px; + + width: 36px; + height: 36px; + + border: none; + border-radius: 50%; + + font-size: 24px; + line-height: 1; + + cursor: pointer; +} diff --git a/public/css/login.css b/public/css/login.css new file mode 100644 index 0000000..91f5dc8 --- /dev/null +++ b/public/css/login.css @@ -0,0 +1,27 @@ + +.login-modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); + display: none; + align-items: center; + justify-content: center; + z-index: 20000; +} + +.login-box { + background: white; + padding: 20px; + border-radius: 12px; + width: 280px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.login-error { + color: red; + font-size: 14px; + min-height: 18px; +} + diff --git a/public/css/map-activities.css b/public/css/map-activities.css new file mode 100644 index 0000000..d180c8d --- /dev/null +++ b/public/css/map-activities.css @@ -0,0 +1,214 @@ +/* =============================== + MAPBOX ACTIVITY MARKERS + =============================== */ + +.activity-marker { + width: 40px; + height: 40px; + + display: flex; + align-items: center; + justify-content: center; + + padding: 0; + margin: 0; + + color: #222; + background: transparent; + border: 0; + + overflow: visible; +} + +.activity-marker-inner { + width: 40px; + height: 40px; + + display: flex; + align-items: center; + justify-content: center; + + box-sizing: border-box; + + color: #222; + background: rgba(245, 245, 245, 0.9); + + border: 2px solid rgba(255, 255, 255, 0.95); + border-radius: 50%; + + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.22), + inset 0 1px 1px rgba(255, 255, 255, 0.7); + + opacity: 1; + visibility: visible; + overflow: visible; +} + +.activity-marker-icon { + position: relative; + z-index: 2; + + display: block; + + width: auto; + height: auto; + + padding: 0; + margin: 0; + + color: #222; + + font-family: + "Segoe UI Emoji", + "Apple Color Emoji", + "Noto Color Emoji", + sans-serif; + + font-size: 23px; + font-weight: 400; + line-height: 1; + + text-align: center; + + opacity: 1; + visibility: visible; + + transform: none; +} + +/* =============================== + LEAFLET ACTIVITY MARKERS + =============================== */ + +.activity-leaflet-marker, +.activity-leaflet-cluster { + margin: 0 !important; + padding: 0 !important; + + background: transparent !important; + border: 0 !important; + + opacity: 1 !important; + visibility: visible !important; + overflow: visible !important; +} + +.activity-start-marker { + width: 42px; + height: 42px; + + display: flex; + align-items: center; + justify-content: center; + + box-sizing: border-box; + + color: #222; + background: rgb(245 245 245 / 92%); + + border: 2px solid rgb(255 255 255 / 95%); + border-radius: 50%; + + box-shadow: + 0 4px 12px rgb(0 0 0 / 22%), + inset 0 1px 1px rgb(255 255 255 / 70%); + + opacity: 1; + visibility: visible; + overflow: visible; +} + +.activity-start-marker-icon { + position: relative; + z-index: 2; + + display: block; + + margin: 0; + padding: 0; + + color: #222; + + font-family: + "Segoe UI Emoji", + "Apple Color Emoji", + "Noto Color Emoji", + sans-serif; + + font-size: 23px; + font-weight: 400; + line-height: 1; + + text-align: center; + + opacity: 1; + visibility: visible; +} + + +/* =============================== + LEAFLET ACTIVITY CLUSTERS + =============================== */ + +.activity-leaflet-cluster { + width: 48px !important; + height: 48px !important; + + margin: 0 !important; + padding: 0 !important; + + background: transparent !important; + border: 0 !important; + + opacity: 1 !important; + visibility: visible !important; + overflow: visible !important; +} + +.leaflet-activity-cluster { + width: 48px; + height: 48px; + + display: flex; + align-items: center; + justify-content: center; + + box-sizing: border-box; + + color: #fff; + background: #ff6b00; + + border: 3px solid rgb(255 255 255 / 95%); + border-radius: 50%; + + box-shadow: + 0 4px 12px rgb(0 0 0 / 28%); + + font-family: inherit; + font-size: 15px; + font-weight: 700; + line-height: 1; + + opacity: 1; + visibility: visible; + overflow: visible; +} + +.leaflet-activity-cluster span { + display: block; + + margin: 0; + padding: 0; + + color: #fff; + + font-size: 15px; + font-weight: 700; + line-height: 1; + + text-align: center; + + opacity: 1; + visibility: visible; +} \ No newline at end of file diff --git a/public/css/map-base.css b/public/css/map-base.css new file mode 100644 index 0000000..8dfeb01 --- /dev/null +++ b/public/css/map-base.css @@ -0,0 +1,248 @@ +/* =============================== + MAPPA GLOBALE + =============================== */ + +.global-map { + position: fixed; + + top: calc( + var(--header-h, 60px) + + var(--safe-top, 0px) + ); + + right: 0; + bottom: 0; + left: 0; + + width: 100%; + height: auto; + + display: none; + + z-index: 1000; +} + +.global-map.open { + display: block; +} + + +/* =============================== + LEAFLET + =============================== */ + +#globalMap, +#globalMap .leaflet-container { + width: 100%; + height: 100%; +} + + +/* =============================== + MAPBOX TERRAIN 3D + =============================== */ + +#terrainMap { + position: fixed; + + top: calc( + var(--header-h, 60px) + + var(--safe-top, 0px) + ); + + right: 0; + bottom: 0; + left: 0; + + width: 100%; + height: auto; + + display: none; + + z-index: 2000; +} + +#terrainMap.open { + display: block; +} + +#terrainMap .mapboxgl-map, +#terrainMap .mapboxgl-canvas-container, +#terrainMap .mapboxgl-canvas { + width: 100%; + height: 100%; +} + + +/* =============================== + GALLERY + =============================== */ + +.gallery.hidden { + display: none; +} + + +/* =============================== + TOGGLE TRACCE ATTIVITÀ + =============================== */ + +.map-toggle-routes { + position: absolute; + + top: 12px; + left: 56px; + + z-index: 2200; + + min-height: 36px; + padding: 7px 12px; + + display: inline-flex; + align-items: center; + justify-content: center; + + background: #fff; + color: #111; + + border: 1px solid #cfcfcf; + border-radius: 18px; + + box-shadow: + 0 2px 8px rgb(0 0 0 / 25%); + + cursor: pointer; + + font-family: inherit; + font-size: 14px; + font-weight: 600; + line-height: 1.2; +} + +.map-toggle-routes:hover { + background: #f7f7f7; +} + +.map-toggle-routes:focus { + outline: 2px solid #2b8cff; + outline-offset: 2px; +} + +.map-toggle-routes.active { + background: #2b8cff; + color: #fff; + border-color: #2b8cff; +} + + +/* =============================== + SELETTORE TIPO MAPPA TERRAIN + =============================== */ + +.terrain-style-control { + position: absolute; + + top: 10px; + right: 56px; + + z-index: 2200; + + display: flex; + align-items: center; + + margin: 0; + padding: 0; +} + +.terrain-style-select { + min-height: 36px; + + max-width: + min( + 210px, + calc(100vw - 130px) + ); + + padding: + 6px 32px 6px 10px; + + border: + 1px solid rgb(0 0 0 / 20%); + + border-radius: 6px; + + background-color: + rgb(255 255 255 / 96%); + + color: #222; + + font-family: inherit; + font-size: 13px; + font-weight: 600; + line-height: 1.2; + + box-shadow: + 0 2px 8px rgb(0 0 0 / 18%); + + cursor: pointer; +} + +.terrain-style-select:hover { + background-color: #fff; +} + +.terrain-style-select:focus { + outline: 2px solid #2b8cff; + outline-offset: 2px; +} + + +/* =============================== + MOBILE + =============================== */ + +@media (max-width: 600px) { + .map-toggle-routes { + top: 56px; + left: 10px; + + max-width: + calc(100vw - 20px); + } + + #terrainMap > .map-toggle-routes { + top: 56px !important; + left: 10px !important; + + max-width: + calc(100vw - 20px); + } + + .terrain-style-control { + top: 10px; + right: 52px; + left: 10px; + } + + .terrain-style-select { + width: 100%; + max-width: none; + } +} + +/* Pulsante tracce sopra Mapbox 3D */ +#terrainMap > .map-toggle-routes { + position: absolute !important; + + top: 12px !important; + left: 12px !important; + + z-index: 9999 !important; + + display: inline-flex; + + visibility: visible !important; + opacity: 1 !important; + + pointer-events: auto !important; +} diff --git a/public/css/map-photos.css b/public/css/map-photos.css new file mode 100644 index 0000000..1a4085d --- /dev/null +++ b/public/css/map-photos.css @@ -0,0 +1,152 @@ +/* =============================== + MARKER FOTO + =============================== */ + +.photo-marker { + width: 46px; + height: 46px; + + border-radius: 10px; + overflow: hidden; + position: relative; + + background: #fff; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + + box-sizing: border-box; /* ← evita ingrandimenti */ +} + +.photo-marker img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + display: block; +} + +/* Leaflet deve rispettare la dimensione */ +.leaflet-marker-icon.photo-marker { + width: 46px !important; + height: 46px !important; +} + +/* =============================== + CLUSTER FOTO (rettangolare) + =============================== */ + +.photo-cluster { + width: 56px; + height: 56px; + position: relative; + border-radius: 12px; + overflow: visible; +} + +.cluster-back { + position: absolute; + top: 6px; + left: 6px; + width: 48px; + height: 48px; + border-radius: 10px; + object-fit: cover; + opacity: 0.5; + filter: blur(1px); + transform: scale(0.95); +} + +.cluster-front { + position: absolute; + top: 0; + left: 0; + width: 48px; + height: 48px; + border-radius: 10px; + object-fit: cover; + box-shadow: 0 2px 6px rgba(0,0,0,0.35); +} + +/* =============================== + MARKER CLUSTER FOTO (Leaflet) + =============================== */ + +.marker-cluster-wrapper { + background: transparent; + border: 0; +} + +/* Wrapper Leaflet — fondamentale per far uscire il badge */ +.marker-cluster { + overflow: visible !important; /* ← SENZA QUESTA il badge viene tagliato */ +} + +.gp-cluster { + position: relative; + border-radius: 50%; /* cluster foto circolare */ + overflow: visible !important; + box-shadow: 0 2px 8px rgba(0,0,0,0.35); + border: 3px solid rgba(255,255,255,0.85); + transition: width .12s, height .12s, font-size .12s; + + z-index: 0; /* stacking context */ +} + +.gp-cluster .cluster-collage { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: repeat(2,1fr); + grid-template-rows: repeat(2,1fr); + + border-radius: 50%; /* ← arrotonda il collage */ + overflow: hidden; /* ← ritaglia gli angoli delle foto */ + + z-index: 1; /* collage sotto */ +} + +/* Ogni quadratino del collage deve ritagliare l'immagine */ +.gp-cluster .cluster-collage div { + position: relative; + overflow: hidden; /* ← fondamentale per il crop */ +} + +/* L'immagine deve riempire e essere centrata */ +.gp-cluster .cluster-collage div img { + width: 100%; + height: 100%; + object-fit: cover; /* ← riempie e taglia */ + object-position: center; /* ← centra */ + display: block; +} + +/* =============================== + BADGE NUMERO — mezzo fuori dal cerchio + =============================== */ + +.gp-cluster .gp-count { + position: absolute; + right: -4px; /* metà fuori */ + top: -4px; /* metà fuori */ + + z-index: 10; /* sopra tutto */ + + background: rgba(0,0,0,0.55); + padding: 4px 7px; + border-radius: 12px; + + color: #fff; + font-weight: 700; + font-size: 12px; + + box-shadow: 0 1px 3px rgba(0,0,0,0.4); +} + +.leaflet-marker-icon.marker-cluster-wrapper { + overflow: visible !important; +} + +.gp-cluster.cluster-sm .gp-count { font-size: 11px; } +.gp-cluster.cluster-md .gp-count { font-size: 13px; } +.gp-cluster.cluster-lg .gp-count { font-size: 15px; } +.gp-cluster.cluster-xl .gp-count { font-size: 17px; } + diff --git a/public/css/map.css b/public/css/map.css new file mode 100644 index 0000000..e0314f8 --- /dev/null +++ b/public/css/map.css @@ -0,0 +1,305 @@ +/* =============================== + MAPPA GLOBALE + =============================== */ + +/* La mappa occupa tutto lo schermo SOTTO l’header */ +.global-map { + position: fixed; + left: 0; + right: 0; + top: calc(var(--header-h, 60px) + var(--safe-top, 0px)); /* niente hard-code */ + bottom: 0; + z-index: 50; + + display: none; /* chiusa di default */ +} + +/* Quando è aperta, visibile */ +.global-map.open { + display: block; +} + +/* La Leaflet container deve riempire il contenitore */ +.global-map, +.global-map .leaflet-container { + width: 100%; + height: 100%; +} + +/* Nasconde la gallery quando la mappa è aperta */ +.gallery.hidden { + display: none; +} + +/* =============================== + MARKER FOTO + =============================== */ + +.photo-marker { + width: 48px; + height: 48px; + border-radius: 10px; + overflow: hidden; + position: relative; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + background: #fff; +} + +.photo-marker img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* =============================== + CLUSTER + =============================== */ + +.photo-cluster { + width: 56px; + height: 56px; + position: relative; + border-radius: 12px; + overflow: visible; +} + +.cluster-back { + position: absolute; + top: 6px; + left: 6px; + width: 48px; + height: 48px; + border-radius: 10px; + object-fit: cover; + opacity: 0.5; + filter: blur(1px); + transform: scale(0.95); +} + +.cluster-front { + position: absolute; + top: 0; + left: 0; + width: 48px; + height: 48px; + border-radius: 10px; + object-fit: cover; + box-shadow: 0 2px 6px rgba(0,0,0,0.35); +} + + + +/* =============================== + MARKER CLUSTER + =============================== */ + + +.marker-cluster-wrapper { background: transparent; border: 0; } +.gp-cluster { + position: relative; + border-radius: 50%; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0,0,0,0.35); + border: 3px solid rgba(255,255,255,0.85); + transition: width .12s, height .12s, font-size .12s; +} +.gp-cluster .cluster-collage { position:absolute; inset:0; display:grid; grid-template-columns: repeat(2,1fr); grid-template-rows: repeat(2,1fr); } +.gp-cluster .cluster-collage div img { width:100%; height:100%; object-fit:cover; display:block; } +.gp-cluster .gp-count { + position:absolute; right:6px; bottom:6px; + background: rgba(0,0,0,0.55); padding:4px 7px; border-radius:12px; + color:#fff; font-weight:700; font-size:12px; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); +} +.gp-cluster.cluster-sm .gp-count { font-size:11px; } +.gp-cluster.cluster-md .gp-count { font-size:13px; } +.gp-cluster.cluster-lg .gp-count { font-size:15px; } +.gp-cluster.cluster-xl .gp-count { font-size:17px; } + + +/* ========================================================= + MODALITÀ ACTIVITIES — override minimale + ========================================================= */ + +/* Mappa globale attività */ +body.activities .global-map { + border-top: 3px solid #007bff; +} + +/* Marker attività (icona/mappa) */ +body.activities .activity-marker { + width: 42px; + height: 42px; + border-radius: 50%; + background: #eef4ff; + border: 2px solid #007bff; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + box-shadow: none; +} + +body.activities .activity-marker img { + width: 70%; + height: 70%; + object-fit: contain; + filter: grayscale(40%); +} + +/* Cluster attività (semplice, non collage) */ +body.activities .activity-cluster { + width: 48px; + height: 48px; + border-radius: 50%; + background: #dce6ff; + border: 3px solid #007bff; + display: flex; + align-items: center; + justify-content: center; + color: #0033aa; + font-weight: 700; + font-size: 14px; + box-shadow: none; +} + +/* Nascondi collage foto nei cluster */ +body.activities .cluster-back, +body.activities .cluster-front, +body.activities .cluster-collage { + display: none !important; +} + +/* Nascondi marker foto */ +body.activities .photo-marker { + display: none !important; +} + +/* Nascondi cluster foto */ +body.activities .photo-cluster, +body.activities .gp-cluster { + display: none !important; +} + +/* =============================== + MAPPA ATTIVITÀ + =============================== */ + +.activity-cluster { + width: 48px; + height: 48px; + + display: flex; + align-items: center; + justify-content: center; + + background: #2b8cff; + color: #fff; + + border: 3px solid rgba(255, 255, 255, 0.9); + border-radius: 50%; + + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + + font-size: 15px; + font-weight: 700; +} + +.activity-start-marker { + width: 42px; + height: 42px; + + display: flex; + align-items: center; + justify-content: center; + + background: #ffffff; + + border: 3px solid #2b8cff; + border-radius: 50%; + + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + + font-size: 20px; +} + +.activity-marker-run { + border-color: #2b8cff; +} + +.activity-marker-bike { + border-color: #3cb44b; +} + +.activity-marker-walk { + border-color: #f58231; +} + +.activity-marker-generic { + border-color: #911eb4; +} + +.map-toggle-routes { + position: absolute; + + top: 12px; + left: 56px; + + z-index: 1000; + + min-height: 36px; + padding: 7px 12px; + + display: inline-flex; + align-items: center; + justify-content: center; + + background: #fff; + color: #111; + + border: 1px solid #cfcfcf; + border-radius: 18px; + + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + + cursor: pointer; + + font-size: 14px; + font-weight: 600; +} + +.map-toggle-routes.active { + background: #2b8cff; + color: #fff; + border-color: #2b8cff; +} + +.activity-map-popup { + min-width: 180px; +} + +.activity-map-popup strong { + display: block; + margin-bottom: 4px; +} + +.activity-popup-open { + margin-top: 8px; + padding: 6px 10px; + + background: #2b8cff; + color: #fff; + + border: 0; + border-radius: 8px; + + cursor: pointer; + + font-size: 13px; + font-weight: 600; +} + +.activity-popup-open:hover { + background: #126fd1; +} + diff --git a/public/css/modal.css b/public/css/modal.css new file mode 100644 index 0000000..641bac2 --- /dev/null +++ b/public/css/modal.css @@ -0,0 +1,400 @@ +/* public/css/modal.css */ + +/* =============================== + MODAL OVERLAY FOTO E VIDEO + =============================== */ + +.modal { + position: fixed; + inset: 0; + + display: none; + align-items: center; + justify-content: center; + + background: rgba(0, 0, 0, 0.8); + + overflow: hidden; + opacity: 0; + + z-index: 20000; + + transition: opacity 160ms ease-out; +} + +.modal.open { + display: flex; + opacity: 1; +} + +/* Effetto vetro opzionale */ +@supports (backdrop-filter: blur(4px)) { + .modal { + backdrop-filter: blur(4px); + } +} + +/* =============================== + CONTENITORE CONTENUTI + + Disponibile anche se nell'HTML + attuale il media è figlio diretto + del modal. + =============================== */ + +.modal-content { + position: relative; + + width: 90vw; + height: 90vh; + + max-width: 1200px; + max-height: 90vh; + + display: flex; + align-items: center; + justify-content: center; + + transform: scale(0.98); + transition: transform 160ms ease-out; +} + +.modal.open .modal-content { + transform: scale(1); +} + +/* =============================== + CONTENITORE FOTO E VIDEO + =============================== */ + +#modalMediaContainer { + position: relative; + + width: 100%; + height: 100%; + + display: flex; + align-items: center; + justify-content: center; + + overflow: hidden; + + z-index: 1; +} + +/* + * Il selettore > limita queste regole + * all'immagine o al video direttamente + * inseriti nel modal. + */ +#modalMediaContainer > img, +#modalMediaContainer > video { + position: relative; + + width: 100%; + height: 100%; + + display: block; + + object-fit: contain; + background: #000; + + z-index: 1; +} + +/* =============================== + PULSANTE CHIUSURA + =============================== */ + +.modal-close { + position: fixed; + + top: calc(8px + env(safe-area-inset-top)); + right: calc(12px + env(safe-area-inset-right)); + + min-width: 44px; + height: 44px; + padding: 0 10px; + + display: inline-flex; + align-items: center; + justify-content: center; + + color: #fff; + background: rgba(0, 0, 0, 0.35); + + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 22px; + + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + + cursor: pointer; + + font-size: 26px; + font-weight: 700; + line-height: 1; + + user-select: none; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; + + z-index: 21000; +} + +/* Area clic più ampia senza cambiare l'aspetto */ +.modal-close::after { + content: ""; + + position: absolute; + inset: -8px; +} + +.modal-close:hover { + background: rgba(0, 0, 0, 0.5); +} + +.modal-close:active { + transform: translateY(1px); +} + +.modal-close:focus-visible { + outline: 2px solid #4c9ffe; + outline-offset: 2px; +} + +/* =============================== + PULSANTE INFO + =============================== */ + +.modal-info-btn { + position: absolute; + + right: 16px; + bottom: 12px; + + width: 40px; + height: 40px; + + display: inline-flex; + align-items: center; + justify-content: center; + + color: #000; + background: #fff; + + border: 1px solid #d0d0d0; + border-radius: 50%; + + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + + cursor: pointer; + font-size: 20px; + + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + + z-index: 21000; +} + +.modal-info-btn:hover { + background: #f7f7f7; +} + +.modal-info-btn:active { + transform: scale(0.95); +} + +.modal-info-btn:focus-visible { + outline: 2px solid #4c9ffe; + outline-offset: 2px; +} + +.modal-info-btn.active { + background: #f7f7f7; + border-color: #cfcfcf; + + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + + transform: none; +} + +/* =============================== + LINK APRI ORIGINALE + =============================== */ + +.modal-open-original { + position: absolute; + + top: 8px; + right: 56px; + + height: 32px; + padding: 0 10px; + + display: inline-flex; + align-items: center; + gap: 6px; + + color: #000; + background: rgba(255, 255, 255, 0.95); + + border: 1px solid #d0d0d0; + border-radius: 16px; + + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + + cursor: pointer; + + font-size: 13px; + text-decoration: none; + + z-index: 21000; +} + +.modal-open-original:hover { + background: #fff; +} + +.modal-open-original:focus-visible { + outline: 2px solid #4c9ffe; + outline-offset: 2px; +} + +/* =============================== + FRECCE DI NAVIGAZIONE + =============================== */ + +.modal-nav-btn { + position: fixed; + + top: calc(50% + env(safe-area-inset-top)); + + width: 44px; + height: 44px; + + display: inline-flex; + align-items: center; + justify-content: center; + + color: #fff; + background: rgba(0, 0, 0, 0.35); + + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 22px; + + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + + cursor: pointer; + + font-size: 22px; + line-height: 1; + + transform: translateY(-50%); + + user-select: none; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; + + transition: + background-color 150ms ease, + transform 50ms ease; + + z-index: 21000; +} + +.modal-nav-btn:hover { + background: rgba(0, 0, 0, 0.5); +} + +.modal-nav-btn:active { + transform: translateY(-50%) translateY(1px); +} + +.modal-nav-btn:focus-visible { + outline: 2px solid #4c9ffe; + outline-offset: 2px; +} + +.modal-nav-btn.prev { + left: calc(12px + env(safe-area-inset-left)); +} + +.modal-nav-btn.next { + right: calc(12px + env(safe-area-inset-right)); +} + +.modal-nav-btn.hidden { + display: none !important; +} + +/* =============================== + MODAL SU MOBILE + =============================== */ + +@media (max-width: 768px) { + .modal-content { + width: 100vw; + height: 100vh; + + max-width: none; + max-height: none; + } + + .modal-open-original { + top: calc(8px + env(safe-area-inset-top)); + } +} + +/* =============================== + STATI GENERALI + =============================== */ + +body.no-scroll { + overflow: hidden; +} + +/* =============================== + CONTRASTO E ACCESSIBILITÀ + =============================== */ + +@media (prefers-contrast: more) { + .modal { + background: rgba(0, 0, 0, 0.9); + } + + .modal-close, + .modal-info-btn, + .modal-open-original { + border-color: #fff; + box-shadow: none; + } +} + +/* Riduzione animazioni */ +@media (prefers-reduced-motion: reduce) { + .modal, + .modal-content { + transition: none !important; + } +} + +/* =============================== + MODAL MEDIA SOPRA ACTIVITY MODAL + =============================== */ + +#modal { + z-index: 20000 !important; +} + +#modal #modalMediaContainer { + z-index: 20001; +} + +#modal .modal-close, +#modal .modal-info-btn, +#modal .modal-open-original, +#modal .modal-nav-btn { + z-index: 21000 !important; +} diff --git a/public/css/operationBanner.css b/public/css/operationBanner.css new file mode 100644 index 0000000..fa67d54 --- /dev/null +++ b/public/css/operationBanner.css @@ -0,0 +1,114 @@ +.operation-banner { + position: fixed; + z-index: 100000; + bottom: 20px; + top: auto; + left: 50%; + transform: translate(-50%, 12px); + width: min(560px, calc(100vw - 28px)); + box-sizing: border-box; + padding: 14px 16px; + color: #fff; + background: rgba(25, 29, 36, .45); + border: 1px solid rgba(255, 255, 255, .14); + border-radius: 14px; + box-shadow: 0 10px 35px rgba(0, 0, 0, .28); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity .22s ease, transform .22s ease, bottom .22s ease; + font: 14px/1.45 system-ui, sans-serif; +} + +.operation-banner.is-visible { + opacity: 1; + visibility: visible; + transform: translate(-50%, 0); + pointer-events: auto; +} + +.operation-banner .ob-row { + display: flex; + align-items: flex-start; + gap: 11px; +} + +.operation-banner .ob-icon { + flex: 0 0 24px; + width: 24px; + text-align: center; + font-size: 19px; +} + +.operation-banner .ob-content { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; + white-space: pre-line; +} + +.operation-banner .ob-close { + border: 0; + padding: 0 2px; + color: #fff; + background: transparent; + font-size: 21px; + cursor: pointer; +} + +.operation-banner .ob-progress { + height: 4px; + margin-top: 12px; + overflow: hidden; + border-radius: 99px; + background: rgba(255, 255, 255, .16); +} + +.operation-banner .ob-progress-fill { + width: 35%; + height: 100%; + border-radius: inherit; + background: #8ab4f8; +} + +.operation-banner .ob-progress-label { + display: none; + margin-top: 9px; + font-size: 12px; + line-height: 1.2; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.operation-banner.is-determinate .ob-progress-label { + display: block; +} + +.operation-banner.is-determinate .ob-progress-fill { + animation: none !important; + transform: none; + transition: width .3s linear; +} + +.operation-banner.is-busy .ob-progress-fill { + animation: operationBannerIndeterminate 1.2s ease-in-out infinite; +} + +.operation-banner.is-success .ob-icon { color: #8fe0a5; } +.operation-banner.is-error .ob-icon { color: #ff9999; } +.operation-banner.is-success .ob-progress { display: none; } + +@keyframes operationBannerIndeterminate { + from { transform: translateX(-120%); } + to { transform: translateX(330%); } +} + +@media (prefers-reduced-motion: reduce) { + .operation-banner, + .operation-banner .ob-progress-fill { + animation: none !important; + transition: none !important; + } +} diff --git a/public/css/optionsSheet.css b/public/css/optionsSheet.css new file mode 100644 index 0000000..cd6fbfe --- /dev/null +++ b/public/css/optionsSheet.css @@ -0,0 +1,95 @@ +/* =============================== + OPTIONS SHEET — bottom sheet + =============================== */ +.options-sheet { + position: fixed; + left: 0; + right: 0; + bottom: 0; + + background: #fff; + border-radius: 16px 16px 0 0; + + max-height: 70vh; + overflow-y: auto; + + transform: translateY(100%); + transition: transform 0.25s ease; + + z-index: 1001; +} + +.options-sheet.open { + transform: translateY(0); +} + +/* =============================== + OVERLAY + =============================== */ +.sheet-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.35); + display: none; + z-index: 1000; +} + +.sheet-overlay.open { + display: block; +} + +/* =============================== + CONTENUTO INTERNO + =============================== */ +#optionsSheet .sheet-content { + padding: 20px; +} + +#optionsSheet h3 { + margin-top: 20px; + margin-bottom: 10px; + font-size: 16px; + color: #444; +} + +.sheet-btn { + width: 100%; + padding: 12px; + margin-bottom: 8px; + text-align: left; + background: #f5f5f5; + border: none; + border-radius: 8px; + font-size: 15px; + cursor: pointer; +} + +.sheet-btn:hover { + background: #e8e8e8; +} + +/* ========================================================= + MODALITÀ ACTIVITIES — override minimale + ========================================================= */ + +/* Sfondo sheet attività */ +body.activities .options-sheet { + background: var(--panel-bg); +} + +/* Titoli attività */ +body.activities #optionsSheet h3 { + color: var(--panel-title-color); +} + +/* Pulsanti attività */ +body.activities .sheet-btn { + background: #eef4ff; + border: 1px solid #d0d8ff; + color: #0033aa; + font-weight: 600; +} + +body.activities .sheet-btn:hover { + background: #e0eaff; +} diff --git a/public/css/utils.css b/public/css/utils.css new file mode 100644 index 0000000..f3a5c7b --- /dev/null +++ b/public/css/utils.css @@ -0,0 +1,38 @@ +.hidden { + display: none !important; +} + +.rounded { + border-radius: 10px; +} + +.shadow-sm { + box-shadow: 0 1px 3px rgba(0,0,0,0.15); +} + +.shadow-md { + box-shadow: 0 2px 6px rgba(0,0,0,0.25); +} + +.fade-in { + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* ========================================================= + Utility opzionali per activities + ========================================================= */ + +/* Colore blu attività */ +.activities-color { + color: #0033aa; +} + +/* Bordo blu attività */ +.activities-border { + border: 1px solid #007bff; +} diff --git a/public/css/workerStatus.css b/public/css/workerStatus.css new file mode 100644 index 0000000..6b5e502 --- /dev/null +++ b/public/css/workerStatus.css @@ -0,0 +1,117 @@ +/* Indicatori silenziosi delle code dei worker */ +.worker-status { + position: fixed; + z-index: 1200; + right: 14px; + bottom: calc(92px + env(safe-area-inset-bottom, 0px)); + display: flex; + flex-direction: column; + gap: 12px; + pointer-events: none; +} + +.worker-status[hidden] { + display: none !important; +} + +.worker-status-item[hidden] { + display: none !important; +} + + +.worker-status-item { + position: relative; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + background: rgba(90, 90, 90, 0.5); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 11px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); +} + +.worker-status-icon { + display: block; + width: 25px; + height: 25px; +} + +.worker-status-count { + position: absolute; + top: -7px; + right: -7px; + min-width: 21px; + height: 21px; + box-sizing: border-box; + padding: 0 5px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.6); + border-radius: 999px; + color: #fff; + background: rgba(45, 45, 45, 0.92); + font-size: 11px; + font-weight: 700; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +@media (max-width: 600px) { + .worker-status { + right: 10px; + bottom: calc(82px + env(safe-area-inset-bottom, 0px)); + gap: 10px; + } + + .worker-status-item { + width: 40px; + height: 40px; + border-radius: 10px; + } + + .worker-status-icon { + width: 23px; + height: 23px; + } +} + +/* Evidenzia le code con errori definitivi senza nascondere i conteggi attivi. */ +.worker-status-item.has-errors { + background: rgba(170, 20, 28, 0.94); + border-color: rgba(255, 115, 115, 0.95); + box-shadow: 0 2px 10px rgba(120, 0, 0, 0.35); +} + +.worker-status-errors { + position: absolute; + right: 0; + bottom: -8px; + padding: 2px 5px; + border: 1px solid rgba(255, 255, 255, 0.8); + border-radius: 999px; + color: #fff; + background: #b00020; + font-size: 9px; + font-weight: 800; + line-height: 1.2; + white-space: nowrap; +} + +.worker-status-errors[hidden] { + display: none !important; +} + +/* Simbolo dedicato alla coda delle attività. */ +.worker-status-emoji { + display: flex; + align-items: center; + justify-content: center; + font-size: 23px; + line-height: 1; +} diff --git a/public/faces.html b/public/faces.html new file mode 100644 index 0000000..56fd02a --- /dev/null +++ b/public/faces.html @@ -0,0 +1,179 @@ + + + + + + + + Persone e volti + + + Verifica autenticazione... + + + +
+ Verifica autenticazione... +
+ +
+ + +
+ Caricamento dei cluster... +
+ + + +
+
+
+

Cluster da revisionare

+ +

+ Gruppi proposti automaticamente dal + riconoscimento facciale. +

+
+
+ +
+
+ + + + +
+ + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..7dc1acc --- /dev/null +++ b/public/index.html @@ -0,0 +1,543 @@ + + + + + + Galleria + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Caricamento…
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/js/activityModal.js b/public/js/activityModal.js new file mode 100644 index 0000000..959c5ae --- /dev/null +++ b/public/js/activityModal.js @@ -0,0 +1,2614 @@ +// public/js/activityModal.js + +let activityMap = null; +let activityMapbox = null; +let activityMapLayer = null; +let activityMapRenderToken = 0; +let activityModalLoadToken = 0; +let activityRouteLayer = null; +let activityStartMarker = null; +let activityEndMarker = null; +let activityCurrentLatLngs = []; +let activityCurrentItem = null; + +let activityMediaVisible = false; +let activityMediaItems = []; +let activityMediaLeafletLayer = null; +let activityMediaMapboxMarkers = []; + +const savedActivityTerrainExaggerationRaw = + localStorage.getItem( + "activityTerrainExaggeration", + ); + +const savedActivityTerrainExaggeration = + savedActivityTerrainExaggerationRaw !== null + ? Number( + savedActivityTerrainExaggerationRaw, + ) + : NaN; + +let activityTerrainExaggeration = + Number.isFinite( + savedActivityTerrainExaggeration, + ) + ? Math.min( + 3, + Math.max( + 0, + savedActivityTerrainExaggeration, + ), + ) + : 1.5; + +function clearActivityMediaMarkers() { + if (activityMediaLeafletLayer) { + try { + activityMediaLeafletLayer.remove(); + } catch {} + + activityMediaLeafletLayer = null; + } + + for ( + const marker of + activityMediaMapboxMarkers + ) { + try { + marker.remove(); + } catch {} + } + + activityMediaMapboxMarkers = []; +} + +function destroyActivityMap() { + activityMapRenderToken++; + clearActivityMediaMarkers(); + + if (activityMapLayer) { + try { + activityMapLayer.remove?.(); + } catch {} + + activityMapLayer = null; + } + + if (activityMap) { + try { + activityMap.remove(); + } catch {} + + activityMap = null; + } + + if (activityMapbox) { + try { + activityMapbox.remove(); + } catch {} + + activityMapbox = null; + } + + const mapElement = + document.getElementById( + "activityMap", + ); + + if (mapElement) { + mapElement.replaceChildren(); + mapElement.classList.remove( + "mapboxgl-map", + ); + } + +activityRouteLayer = null; +activityStartMarker = null; +activityEndMarker = null; +activityCurrentLatLngs = []; +activityCurrentItem = null; +} + +function closeActivityModal() { + activityModalLoadToken++; + + activityMediaVisible = false; + activityMediaItems = []; + + clearActivityMediaMarkers(); + + const modal = + document.getElementById( + "activityModal", + ); + + const container = + document.getElementById( + "activityModalContent", + ); + + const focusedElement = + document.activeElement; + + if ( + modal && + focusedElement instanceof + HTMLElement && + modal.contains( + focusedElement, + ) + ) { + focusedElement.blur(); + } + + destroyActivityMap(); + + if (container) { + container.innerHTML = ""; + } + + if (modal) { + modal.classList.remove( + "open", + ); + + modal.setAttribute( + "aria-hidden", + "true", + ); + + modal.inert = true; + } + + window.currentActivity = null; + + if ( + typeof window.closeInfoPanel === + "function" + ) { + window.closeInfoPanel(); + } + + document.body.style.overflow = + ""; + + document + .getElementById( + "modeToggle", + ) + ?.focus?.({ + preventScroll: true, + }); +} + +function findMediaForActivity( + activity, +) { + const ONE_HOUR_MS = + 60 * 60 * 1000; + + const activityStart = + Number( + activity?.start_time_millis, + ); + + const activityEnd = + Number( + activity?.end_time_millis, + ); + + const minLatitude = + Number( + activity?.min_latitude, + ); + + const maxLatitude = + Number( + activity?.max_latitude, + ); + + const minLongitude = + Number( + activity?.min_longitude, + ); + + const maxLongitude = + Number( + activity?.max_longitude, + ); + + if ( + !Number.isFinite( + activityStart, + ) || + !Number.isFinite( + activityEnd, + ) || + !Number.isFinite( + minLatitude, + ) || + !Number.isFinite( + maxLatitude, + ) || + !Number.isFinite( + minLongitude, + ) || + !Number.isFinite( + maxLongitude, + ) + ) { + console.warn( + "[activity media] Dati attività incompleti", + { + activityStart, + activityEnd, + minLatitude, + maxLatitude, + minLongitude, + maxLongitude, + }, + ); + + return []; + } + + const rangeStart = + activityStart - + ONE_HOUR_MS; + + const rangeEnd = + activityEnd + + ONE_HOUR_MS; + + const mediaItems = + window + .getLocalItemsForMode?.( + "photos", + ) || []; + + const results = + mediaItems.filter( + (media) => { + const isImage = + media.mime_type + ?.startsWith( + "image/", + ); + + const isVideo = + media.mime_type + ?.startsWith( + "video/", + ); + + if ( + !isImage && + !isVideo + ) { + return false; + } + + if (media.deleted_at) { + return false; + } + + const latitude = + Number( + media.gps?.lat, + ); + + const longitude = + Number( + media.gps?.lng, + ); + + const mediaTime = + media.taken_at + ? new Date( + media.taken_at, + ).getTime() + : NaN; + + if ( + !Number.isFinite( + latitude, + ) || + !Number.isFinite( + longitude, + ) || + !Number.isFinite( + mediaTime, + ) + ) { + return false; + } + + const insideTime = + mediaTime >= + rangeStart && + mediaTime <= + rangeEnd; + + const insideBounds = + latitude >= + minLatitude && + latitude <= + maxLatitude && + longitude >= + minLongitude && + longitude <= + maxLongitude; + + return ( + insideTime && + insideBounds + ); + }, + ); + + results.sort( + (first, second) => + new Date( + first.taken_at, + ).getTime() - + new Date( + second.taken_at, + ).getTime(), + ); + + console.log( + "[activity media] risultati", + { + activityId: + activity.id, + + totalMedia: + mediaItems.length, + + found: + results.length, + + rangeStart: + new Date( + rangeStart, + ).toISOString(), + + rangeEnd: + new Date( + rangeEnd, + ).toISOString(), + + bounds: { + minLatitude, + maxLatitude, + minLongitude, + maxLongitude, + }, + }, + ); + + return results; +} + +window.openActivityModal = + async function ( + item, + list = [], + index = 0, + ) { + window.closeBottomSheet?.(); + + const modal = + document.getElementById( + "activityModal", + ); + + const container = + document.getElementById( + "activityModalContent", + ); + + if ( + !modal || + !container + ) { + console.error( + "[activity modal] Mancano activityModal o activityModalContent", + ); + + return; + } + + if (!item) { + return; + } + + const loadToken = + ++activityModalLoadToken; + + destroyActivityMap(); + activityMediaVisible = false; + activityMediaItems = []; + + window.currentActivity = + item; + + window.activityModalList = + Array.isArray(list) + ? list + : []; + + window.activityModalIndex = + Number.isInteger(index) + ? index + : 0; + + activityCurrentItem = + item; + + const title = + item.display_name || + item.route_name || + item.title || + item.file_name || + item.name || + "Attività"; + + const distance = + Number( + item.distance_meters, + ); + + const duration = + Number( + item.duration_millis, + ); + + const elevation = + Number( + item.elevation_gain_meters, + ); + + const previewPoints = + Array.isArray( + item.track_preview, + ) + ? item.track_preview + : []; + + const previewLatLngs = + getActivityLatLngs( + previewPoints, + ); + + activityCurrentLatLngs = + previewLatLngs; + + container.innerHTML = ` +
+
+ +
+ + + + +
+ +
+ + + + Caricamento traccia completa... + +
+ + + + + +
+
+ ${escapeActivityHtml( + title, + )} +
+ + ${ + item.sport + ? ` +
+ ${escapeActivityHtml( + item.sport, + )} +
+ ` + : "" + } + +
+ ${ + Number.isFinite( + distance, + ) + ? `${( + distance / + 1000 + ).toFixed( + 1, + )} km` + : "Distanza non disponibile" + } + · + ${formatActivityModalDuration( + duration, + )} +
+ + ${ + Number.isFinite( + elevation, + ) && + elevation > 0 + ? ` +
+ ⛰ ${Math.round( + elevation, + )} m D+ +
+ ` + : "" + } +
+
+ `; + +modal.inert = false; + +modal.classList.add( + "open", +); + +modal.setAttribute( + "aria-hidden", + "false", +); + + document.body.style.overflow = + "hidden"; + + const activityInfoBtn = + document.getElementById( + "activityInfoBtn", + ); + +const activityMediaBtn = + document.getElementById( + "activityMediaBtn", + ); + +initializeActivityTerrainControl(); + +if (activityMediaBtn) { + activityMediaBtn.setAttribute( + "aria-pressed", + "false", + ); + + activityMediaBtn.onclick = + (event) => { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + /* + * Primo click: esegue la ricerca. + * I click successivi riutilizzano + * i risultati già trovati. + */ + if ( + activityMediaItems.length === 0 + ) { + activityMediaItems = + findMediaForActivity( + item, + ); + } + + if ( + activityMediaItems.length === 0 + ) { + activityMediaVisible = + false; + + activityMediaBtn.classList.remove( + "active", + ); + + activityMediaBtn.setAttribute( + "aria-pressed", + "false", + ); + + activityMediaBtn.title = + "Nessuna foto o video trovati"; + + console.log( + "[activity media] nessun risultato", + { + activityId: + item.id, + }, + ); + + return; + } + + activityMediaVisible = + !activityMediaVisible; + + activityMediaBtn.classList.toggle( + "active", + activityMediaVisible, + ); + + activityMediaBtn.setAttribute( + "aria-pressed", + String( + activityMediaVisible, + ), + ); + + activityMediaBtn.title = + activityMediaVisible + ? "Nascondi foto e video" + : "Mostra foto e video"; + + renderActivityMediaMarkers(); + + console.log( + "[activity media] visibilità", + { + activityId: + item.id, + + visible: + activityMediaVisible, + + media: + activityMediaItems.length, + + engine: + activityMapbox + ? "mapbox" + : activityMap + ? "leaflet" + : null, + }, + ); + }; +} + + if (activityInfoBtn) { + activityInfoBtn.onclick = + (event) => { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + window.currentActivity = + item; + + if ( + typeof window + .toggleInfoPanel === + "function" + ) { + window.toggleInfoPanel( + item, + ); + } else if ( + typeof window + .openInfoPanel === + "function" + ) { + window.openInfoPanel( + item, + ); + } else { + console.error( + "[activity info] Nessuna funzione info panel disponibile", + ); + } + }; + } + + /* + * Attende che il modal sia realmente + * visibile e dimensionato. + */ + await new Promise( + (resolve) => { + requestAnimationFrame( + () => { + requestAnimationFrame( + resolve, + ); + }, + ); + }, + ); + + /* + * Interrompe l'apertura se nel frattempo + * il modal è stato chiuso o è stata aperta + * un'altra attività. + */ + if ( + loadToken !== + activityModalLoadToken || + !modal.classList.contains( + "open", + ) || + window.currentActivity !== + item + ) { + return; + } + + const mapElement = + document.getElementById( + "activityMap", + ); + + if (!mapElement) { + return; + } + + /* + * Inizializza subito la basemap usando + * track_preview per la traccia iniziale + * e per il calcolo dei bounds. + */ + initializeActivityMapSelector( + item, + previewPoints, + ); + + console.log( + "[activity modal] preview iniziale", + { + id: + item.id, + + previewPoints: + previewLatLngs.length, + + expected: + Number( + item.point_count, + ) || null, + }, + ); + + /* + * Recupera in background la traccia + * completa. La basemap è già visibile. + */ + let fullPoints = []; + + try { + if ( + typeof window + .getActivityRoutePoints !== + "function" + ) { + throw new Error( + "Funzione getActivityRoutePoints non disponibile", + ); + } + + fullPoints = + await window + .getActivityRoutePoints( + item, + { + full: true, + }, + ); + } catch (error) { + console.error( + "[activity modal] Errore caricamento traccia completa:", + error, + ); + + if ( + loadToken !== + activityModalLoadToken || + !modal.classList.contains( + "open", + ) || + window.currentActivity !== + item + ) { + return; + } + + const loadingIndicator = + document.getElementById( + "activityRouteLoading", + ); + + if (loadingIndicator) { + loadingIndicator.classList.add( + "is-error", + ); + + loadingIndicator.replaceChildren(); + + const errorText = + document.createElement( + "span", + ); + + errorText.textContent = + "Traccia completa non disponibile"; + + loadingIndicator.appendChild( + errorText, + ); + + setTimeout( + () => { + if ( + loadToken === + activityModalLoadToken && + window.currentActivity === + item + ) { + loadingIndicator.remove(); + } + }, + 3000, + ); + } + + mapElement.setAttribute( + "aria-busy", + "false", + ); + + /* + * La preview resta visibile anche + * quando il caricamento completo fallisce. + */ + return; + } + + /* + * Ignora una risposta tardiva se il modal + * è stato chiuso oppure se è stata aperta + * un'altra attività. + */ + if ( + loadToken !== + activityModalLoadToken || + !modal.classList.contains( + "open", + ) || + window.currentActivity !== + item + ) { + return; + } + + if ( + !Array.isArray( + fullPoints, + ) + ) { + fullPoints = []; + } + + console.log( + "[activity modal] traccia completa", + { + id: + item.id, + + expected: + Number( + item.point_count, + ) || null, + + loaded: + fullPoints.length, + + preview: + previewPoints.length, + + format: + item.format || + item.file_extension || + null, + }, + ); + + if ( + fullPoints.length >= 2 + ) { + const fullLatLngs = + getActivityLatLngs( + fullPoints, + ); + + activityCurrentItem = + item; + + activityCurrentLatLngs = + fullLatLngs; + + /* + * Aggiorna solamente la geometria. + * + * Leaflet aggiorna polyline e marker. + * Mapbox aggiorna la source GeoJSON. + * La basemap non viene ricreata. + */ +const updated = + updateActivityModalRoute( + item, + fullPoints, + false, + ); + + + /* + * Se Mapbox sta ancora caricando lo stile, + * updateActivityModalRoute restituisce false. + * I punti completi restano comunque salvati + * in activityCurrentLatLngs e saranno usati + * dal callback "load" di Mapbox. + */ + if (!updated) { + console.log( + "[activity modal] aggiornamento traccia differito", + { + id: + item.id, + + points: + fullLatLngs.length, + + mapboxLoading: + Boolean( + activityMapbox && + !activityMapbox + .isStyleLoaded?.(), + ), + }, + ); + } + } else { + console.warn( + "[activity modal] Traccia completa senza punti GPS sufficienti", + { + id: + item.id, + + loaded: + fullPoints.length, + + preview: + previewPoints.length, + }, + ); + } + + mapElement.setAttribute( + "aria-busy", + "false", + ); + + const loadingIndicator = + document.getElementById( + "activityRouteLoading", + ); + + loadingIndicator?.remove(); + }; + + +function getActivityLatLngs( + points, +) { + if (!Array.isArray(points)) { + return []; + } + + return points + .map( + (point) => [ + Number(point?.lat), + Number(point?.lng), + ], + ) + .filter( + ([lat, lng]) => + Number.isFinite(lat) && + Number.isFinite(lng), + ); +} + +function getActivityMapCenter( + item, + latlngs, +) { + if (latlngs.length > 0) { + return latlngs[0]; + } + + const latitude = + Number( + item?.center_latitude, + ); + + const longitude = + Number( + item?.center_longitude, + ); + + if ( + Number.isFinite(latitude) && + Number.isFinite(longitude) + ) { + return [ + latitude, + longitude, + ]; + } + + return [42.5, 12.5]; +} + +function updateActivityLeafletRoute( + item, + latlngs, + fitBounds = true, +) { + if (!activityMap) { + return; + } + + if (activityRouteLayer) { + activityRouteLayer.remove(); + activityRouteLayer = null; + } + + if (activityStartMarker) { + activityStartMarker.remove(); + activityStartMarker = null; + } + + if (activityEndMarker) { + activityEndMarker.remove(); + activityEndMarker = null; + } + + if (latlngs.length >= 2) { + activityRouteLayer = + L.polyline( + latlngs, + { + color: "#2b8cff", + weight: 5, + opacity: 1, + lineCap: "round", + lineJoin: "round", + }, + ).addTo(activityMap); + + activityStartMarker = + L.circleMarker( + latlngs[0], + { + radius: 6, + color: "#087f23", + fillColor: "#18b83f", + fillOpacity: 1, + }, + ) + .bindTooltip("Partenza") + .addTo(activityMap); + + activityEndMarker = + L.circleMarker( + latlngs[ + latlngs.length - 1 + ], + { + radius: 6, + color: "#b00020", + fillColor: "#e32636", + fillOpacity: 1, + }, + ) + .bindTooltip("Arrivo") + .addTo(activityMap); + + if (fitBounds) { + activityMap.fitBounds( + activityRouteLayer.getBounds(), + { + paddingTopLeft: + [30, 70], + + paddingBottomRight: + [30, 120], + + maxZoom: 16, + }, + ); + } + + return; + } + + activityMap.setView( + getActivityMapCenter( + item, + latlngs, + ), + 12, + ); +} + +function renderActivityMediaLeaflet() { + if ( + !activityMap || + !activityMediaVisible || + activityMediaItems.length === 0 + ) { + return; + } + + if (activityMediaLeafletLayer) { + try { + activityMediaLeafletLayer.remove(); + } catch {} + } + + activityMediaLeafletLayer = + L.layerGroup(); + + for ( + const media of + activityMediaItems + ) { + const latitude = + Number( + media?.gps?.lat, + ); + + const longitude = + Number( + media?.gps?.lng, + ); + + if ( + !Number.isFinite(latitude) || + !Number.isFinite(longitude) + ) { + continue; + } + + let marker = + window.PhotoMarkerFactory + ?.createIndependent?.( + media, + ); + + /* + * Fallback nel caso createIndependent() + * non sia ancora disponibile. + */ + if (!marker) { + const thumb = + window.toAbsoluteUrl?.( + media.thub1 || + media.thub2 || + media.path, + media.name, + "thumbs", + media.cartella, + ) || ""; + + const icon = + L.icon({ + iconUrl: thumb, + iconSize: [46, 46], + iconAnchor: [23, 23], + className: + "photo-marker activity-media-marker", + }); + + marker = + L.marker( + [ + latitude, + longitude, + ], + { + icon, + + title: + media.display_name || + media.name || + "", + }, + ); + + marker.on( + "click", + () => { + window.openPhotoMapItem?.( + media, + ); + }, + ); + } + + marker.addTo( + activityMediaLeafletLayer, + ); + } + + activityMediaLeafletLayer.addTo( + activityMap, + ); + + console.log( + "[activity media] marker Leaflet", + { + media: + activityMediaItems.length, + + markers: + activityMediaLeafletLayer + .getLayers?.() + ?.length ?? null, + }, + ); +} + +function renderActivityLeafletMap( + mapElement, + styleId, + item, + latlngs, +) { + destroyActivityMap(); + + activityCurrentItem = + item; + + activityCurrentLatLngs = + latlngs; + + activityMap = + L.map( + mapElement, + { + zoomControl: true, + attributionControl: true, + }, + ); + + activityMapLayer = + window.MapBaseLayers + ?.createLeafletLayer?.( + styleId, + ); + + if (!activityMapLayer) { + activityMapLayer = + window.MapBaseLayers + ?.createLeafletLayer?.( + "osm", + ); + } + + activityMapLayer?.addTo?.( + activityMap, + ); + + updateActivityLeafletRoute( + item, + latlngs, + true, + ); + + renderActivityMediaMarkers(); + + requestAnimationFrame( + () => { + activityMap + ?.invalidateSize?.( + true, + ); + }, + ); +} + +function updateActivityMapboxRoute( + item, + latlngs, + fitBounds = true, +) { + if ( + !activityMapbox || + !activityMapbox.isStyleLoaded?.() + ) { + return false; + } + + if ( + !Array.isArray(latlngs) || + latlngs.length < 2 + ) { + return false; + } + + const coordinates = + latlngs + .map( + ([lat, lng]) => [ + Number(lng), + Number(lat), + ], + ) + .filter( + ([lng, lat]) => + Number.isFinite(lng) && + Number.isFinite(lat), + ); + + if (coordinates.length < 2) { + return false; + } + + const data = { + type: "FeatureCollection", + + features: [ + { + type: "Feature", + + geometry: { + type: "LineString", + coordinates, + }, + + properties: {}, + }, + ], + }; + + const source = + activityMapbox.getSource( + "activity-modal-route", + ); + + if (source) { + source.setData( + data, + ); + } else { + activityMapbox.addSource( + "activity-modal-route", + { + type: "geojson", + data, + }, + ); + } + + if ( + !activityMapbox.getLayer( + "activity-modal-route-line", + ) + ) { + activityMapbox.addLayer({ + id: + "activity-modal-route-line", + + type: + "line", + + source: + "activity-modal-route", + + layout: { + "line-cap": + "round", + + "line-join": + "round", + }, + + paint: { + "line-color": + "#2b8cff", + + "line-width": + 5, + + "line-opacity": + 1, + }, + }); + } + + /* + * Quando arriva la traccia FIT completa, + * aggiorna soltanto la source GeoJSON. + * Non modifica la camera impostata + * dall'utente. + */ + if (!fitBounds) { + console.log( + "[activity modal] traccia Mapbox aggiornata senza modificare la camera", + { + points: + coordinates.length, + + zoom: + activityMapbox.getZoom?.(), + + pitch: + activityMapbox.getPitch?.(), + + bearing: + activityMapbox.getBearing?.(), + + center: + activityMapbox + .getCenter?.(), + }, + ); + + return true; + } + + /* + * I bounds vengono applicati soltanto + * durante il rendering iniziale, + * normalmente usando la preview. + */ + const bounds = + new mapboxgl.LngLatBounds(); + + for ( + const coordinate of coordinates + ) { + bounds.extend( + coordinate, + ); + } + + const isTerrain3D = + activityMapbox + .getTerrain?.() != null; + + activityMapbox.fitBounds( + bounds, + { + padding: { + top: 70, + right: 30, + bottom: 120, + left: 30, + }, + + maxZoom: 16, + duration: 0, + + pitch: + isTerrain3D + ? 60 + : 0, + + bearing: + isTerrain3D + ? -20 + : 0, + }, + ); + + console.log( + "[activity modal] traccia Mapbox aggiornata con bounds", + { + points: + coordinates.length, + + terrain: + isTerrain3D, + }, + ); + + return true; +} + +function renderActivityMediaMapbox() { + if ( + !activityMapbox || + !activityMediaVisible || + activityMediaItems.length === 0 + ) { + return; + } + + for ( + const marker of + activityMediaMapboxMarkers + ) { + try { + marker.remove(); + } catch {} + } + + activityMediaMapboxMarkers = []; + + for ( + const media of + activityMediaItems + ) { + const latitude = + Number( + media?.gps?.lat, + ); + + const longitude = + Number( + media?.gps?.lng, + ); + + if ( + !Number.isFinite(latitude) || + !Number.isFinite(longitude) + ) { + continue; + } + + const thumb = + window.toAbsoluteUrl?.( + media.thub1 || + media.thub2 || + media.path, + media.name, + "thumbs", + media.cartella, + ) || ""; + + const element = + document.createElement( + "button", + ); + + element.type = + "button"; + + element.className = + "activity-media-mapbox-marker"; + + element.setAttribute( + "aria-label", + media.name || + "Apri contenuto", + ); + + const image = + document.createElement( + "img", + ); + + image.src = + thumb; + + image.alt = + media.name || ""; + + image.width = + 46; + + image.height = + 46; + + element.appendChild( + image, + ); + + if ( + media.mime_type + ?.startsWith( + "video/", + ) + ) { + const play = + document.createElement( + "span", + ); + + play.className = + "activity-media-video-icon"; + + play.textContent = + "▶"; + + play.setAttribute( + "aria-hidden", + "true", + ); + + element.appendChild( + play, + ); + } + + element.addEventListener( + "pointerdown", + (event) => { + event.stopPropagation(); + }, + ); + + element.addEventListener( + "click", + (event) => { + event.preventDefault(); + event.stopPropagation(); + + window.openPhotoMapItem?.( + media, + ); + }, + ); + + const marker = + new mapboxgl.Marker({ + element, + anchor: "center", + }) + .setLngLat([ + longitude, + latitude, + ]) + .addTo( + activityMapbox, + ); + + marker.__item = + media; + + activityMediaMapboxMarkers.push( + marker, + ); + } + + console.log( + "[activity media] marker Mapbox", + { + media: + activityMediaItems.length, + + markers: + activityMediaMapboxMarkers.length, + }, + ); +} + +function renderActivityMediaMarkers() { + clearActivityMediaMarkers(); + + if ( + !activityMediaVisible || + activityMediaItems.length === 0 + ) { + return; + } + + if (activityMap) { + renderActivityMediaLeaflet(); + return; + } + + if (activityMapbox) { + renderActivityMediaMapbox(); + } +} + +function updateActivityTerrainControlVisibility( + styleId, +) { + const control = + document.getElementById( + "activityTerrainControl", + ); + + if (!control) { + return; + } + + control.hidden = + styleId !== "terrain3d"; +} + +function applyActivityTerrainExaggeration( + exaggeration, +) { + const numericValue = + Number( + exaggeration, + ); + + const normalizedValue = + Number.isFinite( + numericValue, + ) + ? Math.min( + 3, + Math.max( + 0, + numericValue, + ), + ) + : 1.5; + + activityTerrainExaggeration = + normalizedValue; + + localStorage.setItem( + "activityTerrainExaggeration", + String( + normalizedValue, + ), + ); + + const output = + document.getElementById( + "activityTerrainExaggerationValue", + ); + + if (output) { + output.textContent = + `${normalizedValue.toFixed(1)}×`; + } + + if ( + !activityMapbox || + !activityMapbox.isStyleLoaded?.() || + !activityMapbox.getSource( + "activity-modal-dem", + ) + ) { + return; + } + + activityMapbox.setTerrain({ + source: + "activity-modal-dem", + + exaggeration: + normalizedValue, + }); + + console.log( + "[activity modal] altezza Terrain 3D", + { + exaggeration: + normalizedValue, + + zoom: + activityMapbox.getZoom?.(), + + pitch: + activityMapbox.getPitch?.(), + + bearing: + activityMapbox.getBearing?.(), + }, + ); +} + +function initializeActivityTerrainControl() { + const range = + document.getElementById( + "activityTerrainExaggeration", + ); + + const output = + document.getElementById( + "activityTerrainExaggerationValue", + ); + + if (!range) { + return; + } + + range.value = + String( + activityTerrainExaggeration, + ); + + if (output) { + output.textContent = + `${activityTerrainExaggeration.toFixed(1)}×`; + } + + range.addEventListener( + "input", + () => { + applyActivityTerrainExaggeration( + range.value, + ); + }, + ); +} + +function renderActivityMapboxMap( + mapElement, + styleId, + item, + latlngs, +) { + destroyActivityMap(); + + const token = + window.AppConfig + ?.mapboxToken || + ""; + + if ( + !token || + !window.mapboxgl + ) { + renderActivityLeafletMap( + mapElement, + "osm", + item, + latlngs, + ); + + return; + } + + const renderToken = + ++activityMapRenderToken; + + const isTerrain3D = + styleId === + "terrain3d"; + + mapboxgl.accessToken = + token; + + activityCurrentItem = + item; + + activityCurrentLatLngs = + Array.isArray(latlngs) + ? latlngs + : []; + + const centerLatLng = + getActivityMapCenter( + item, + activityCurrentLatLngs, + ); + + activityMapbox = + new mapboxgl.Map({ + container: + mapElement, + + style: + window.MapBaseLayers + ?.getMapboxStyleUrl?.( + styleId, + ) || + "mapbox://styles/mapbox/outdoors-v12", + + center: [ + centerLatLng[1], + centerLatLng[0], + ], + + zoom: + 10, + + pitch: + isTerrain3D + ? 60 + : 0, + + bearing: + isTerrain3D + ? -20 + : 0, + + antialias: + true, + }); + +activityMapbox.addControl( + new mapboxgl.NavigationControl({ + showCompass: true, + showZoom: true, + visualizePitch: true, + }), + "top-left", +); + + activityMapbox.on( + "load", + () => { + if ( + !activityMapbox || + renderToken !== + activityMapRenderToken + ) { + return; + } + + /* + * Attiva il DEM e l'inclinazione + * soltanto per Terrain 3D. + */ + if ( + isTerrain3D && + !activityMapbox.getSource( + "activity-modal-dem", + ) + ) { + activityMapbox.addSource( + "activity-modal-dem", + { + type: + "raster-dem", + + url: + "mapbox://mapbox.mapbox-terrain-dem-v1", + + tileSize: + 512, + + maxzoom: + 14, + }, + ); + + activityMapbox.setTerrain({ + source: + "activity-modal-dem", + + exaggeration: + activityTerrainExaggeration, + }); + } + +const terrainOutput = + document.getElementById( + "activityTerrainExaggerationValue", + ); + +if (terrainOutput) { + terrainOutput.textContent = + `${activityTerrainExaggeration.toFixed(1)}×`; +} + + /* + * Usa i punti correnti. + * + * Normalmente, al primo caricamento, + * questi punti appartengono alla preview. + * + * Se nel frattempo è arrivata la traccia + * completa, activityCurrentLatLngs contiene + * già tutti i punti FIT. + */ + const currentLatLngs = + Array.isArray( + activityCurrentLatLngs, + ) + ? activityCurrentLatLngs + : []; + + if ( + currentLatLngs.length < 2 + ) { + const currentCenter = + getActivityMapCenter( + activityCurrentItem || + item, + currentLatLngs, + ); + + activityMapbox.jumpTo({ + center: [ + currentCenter[1], + currentCenter[0], + ], + + zoom: + 12, + + pitch: + isTerrain3D + ? 60 + : 0, + + bearing: + isTerrain3D + ? -20 + : 0, + }); + + renderActivityMediaMarkers(); + + return; + } + + const coordinates = + currentLatLngs + .map( + ([lat, lng]) => [ + Number(lng), + Number(lat), + ], + ) + .filter( + ([lng, lat]) => + Number.isFinite(lng) && + Number.isFinite(lat), + ); + + if ( + coordinates.length < 2 + ) { + renderActivityMediaMarkers(); + return; + } + + const routeData = { + type: + "FeatureCollection", + + features: [ + { + type: + "Feature", + + geometry: { + type: + "LineString", + + coordinates, + }, + + properties: {}, + }, + ], + }; + + /* + * La source normalmente non esiste + * al primo caricamento. + * + * Il controllo rende però la funzione + * sicura anche se il callback viene + * eseguito dopo un aggiornamento. + */ + const existingSource = + activityMapbox.getSource( + "activity-modal-route", + ); + + if (existingSource) { + existingSource.setData( + routeData, + ); + } else { + activityMapbox.addSource( + "activity-modal-route", + { + type: + "geojson", + + data: + routeData, + }, + ); + } + + if ( + !activityMapbox.getLayer( + "activity-modal-route-line", + ) + ) { + activityMapbox.addLayer({ + id: + "activity-modal-route-line", + + type: + "line", + + source: + "activity-modal-route", + + layout: { + "line-cap": + "round", + + "line-join": + "round", + }, + + paint: { + "line-color": + "#2b8cff", + + "line-width": + 5, + + "line-opacity": + 1, + }, + }); + } + + const bounds = + new mapboxgl.LngLatBounds(); + + for ( + const coordinate of + coordinates + ) { + bounds.extend( + coordinate, + ); + } + + activityMapbox.fitBounds( + bounds, + { + padding: { + top: + 70, + + right: + 30, + + bottom: + 120, + + left: + 30, + }, + + maxZoom: + 16, + + duration: + 0, + + pitch: + isTerrain3D + ? 60 + : 0, + + bearing: + isTerrain3D + ? -20 + : 0, + }, + ); + + renderActivityMediaMarkers(); + + console.log( + "[activity modal] Mapbox inizializzata", + { + styleId, + + terrain: + isTerrain3D, + + points: + coordinates.length, + + previewPoints: + Array.isArray( + item?.track_preview, + ) + ? item.track_preview + .length + : 0, + }, + ); + }, + ); + + activityMapbox.on( + "error", + (event) => { + if ( + renderToken !== + activityMapRenderToken + ) { + return; + } + + console.error( + "[activity modal] Errore Mapbox:", + event?.error || + event, + ); + }, + ); +} + +function updateActivityModalRoute( + item, + points, + fitBounds = false, +) { + const latlngs = + getActivityLatLngs( + points, + ); + + if ( + latlngs.length < 2 + ) { + return false; + } + + activityCurrentItem = + item; + + activityCurrentLatLngs = + latlngs; + + if (activityMap) { + updateActivityLeafletRoute( + item, + latlngs, + fitBounds, + ); + + return true; + } + + if (activityMapbox) { + if ( + activityMapbox + .isStyleLoaded?.() + ) { + return ( + updateActivityMapboxRoute( + item, + latlngs, + fitBounds, + ) !== false + ); + } + + return false; + } + + return false; +} + +function initializeActivityMapSelector( + item, + points, +) { + const mapElement = + document.getElementById( + "activityMap", + ); + + const select = + document.getElementById( + "activityMapStyle", + ); + + if ( + !mapElement || + !select + ) { + console.error( + "[activity modal] Mappa o selettore non disponibile", + ); + + return; + } + + select.disabled = false; + + const latlngs = + getActivityLatLngs( + points, + ); + + const availableStyles = + window.MapBaseLayers + ?.getAvailableStyles?.() || + []; + + const hasMapboxToken = + Boolean( + window.AppConfig + ?.mapboxToken, + ); + + const fallbackStyles = [ + { + id: "osm", + label: "OpenStreetMap", + }, + { + id: "opentopo", + label: "Topografica", + }, + ]; + + if (hasMapboxToken) { + fallbackStyles.push( + { + id: "mapboxStreets", + label: "Mapbox Streets", + }, + { + id: "mapboxOutdoors", + label: "Mapbox Outdoors", + }, + { + id: "mapboxSatellite", + label: "Mapbox Satellite", + }, + { + id: "mapboxSatelliteStreets", + label: + "Mapbox Satellite + strade", + }, + { + id: "terrain3d", + label: "Terrain 3D", + }, + ); + } + + const styles = + availableStyles.length > 0 + ? availableStyles + : fallbackStyles; + + select.replaceChildren(); + + for (const style of styles) { + const option = + document.createElement( + "option", + ); + + option.value = + style.id; + + option.textContent = + style.label; + + select.appendChild( + option, + ); + } + + const savedStyle = + localStorage.getItem( + "selectedMapStyle", + ); + + const savedStyleAvailable = + styles.some( + (style) => + style.id === + savedStyle, + ); + + const initialStyle = + savedStyleAvailable + ? savedStyle + : "osm"; + + select.value = + initialStyle; + + function renderSelectedStyle( + styleId, + ) { + const selectedStyle = + styles.some( + (style) => + style.id === + styleId, + ) + ? styleId + : "osm"; + + updateActivityTerrainControlVisibility( + selectedStyle, + ); + + const currentLatLngs = + activityCurrentLatLngs + .length >= 2 + ? activityCurrentLatLngs + : latlngs; + + if ( + selectedStyle === + "terrain3d" + ) { + renderActivityMapboxMap( + mapElement, + selectedStyle, + activityCurrentItem || + item, + currentLatLngs, + ); + + return; + } + + renderActivityLeafletMap( + mapElement, + selectedStyle, + activityCurrentItem || + item, + currentLatLngs, + ); + } + + select.addEventListener( + "change", + () => { + const selectedStyle = + select.value; + + localStorage.setItem( + "selectedMapStyle", + selectedStyle, + ); + + renderSelectedStyle( + selectedStyle, + ); + }, + ); + + renderSelectedStyle( + initialStyle, + ); +} + + + +function formatActivityModalDuration(milliseconds) { + if (!Number.isFinite(milliseconds)) { + return "Durata non disponibile"; + } + + const totalSeconds = Math.round(milliseconds / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + + return hours > 0 + ? `${hours}h ${minutes}m` + : `${minutes}m`; +} + +function escapeActivityHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +window.closeActivityModal = closeActivityModal; + +document.addEventListener("DOMContentLoaded", () => { + const modal = document.getElementById("activityModal"); + const closeButton = document.getElementById("activityModalClose"); + + closeButton?.addEventListener("click", event => { + event.stopPropagation(); + closeActivityModal(); + }); + + modal?.addEventListener("click", event => { + if (event.target === modal) { + closeActivityModal(); + } + }); + + document.addEventListener("keydown", event => { + if ( + event.key === "Escape" && + modal?.classList.contains("open") + ) { + closeActivityModal(); + } + }); +}); diff --git a/public/js/activityThumbs.js b/public/js/activityThumbs.js new file mode 100644 index 0000000..596c5b5 --- /dev/null +++ b/public/js/activityThumbs.js @@ -0,0 +1,554 @@ +// public/js/activityThumbs.js +// Caricamento, parsing e rendering delle anteprime dei percorsi. + +(() => { + "use strict"; + + const routeCache = window.activityRouteCache || new Map(); + window.activityRouteCache = routeCache; + + function normalizePoint(point) { + if (!point) return null; + + if (Number.isFinite(Number(point.lat)) && Number.isFinite(Number(point.lng))) { + return { lat: Number(point.lat), lng: Number(point.lng) }; + } + if (Number.isFinite(Number(point.latitude)) && Number.isFinite(Number(point.longitude))) { + return { lat: Number(point.latitude), lng: Number(point.longitude) }; + } + if (Number.isFinite(Number(point.lat)) && Number.isFinite(Number(point.lon))) { + return { lat: Number(point.lat), lng: Number(point.lon) }; + } + if (Array.isArray(point) && point.length >= 2) { + const lng = Number(point[0]); + const lat = Number(point[1]); + if (Number.isFinite(lat) && Number.isFinite(lng)) return { lat, lng }; + } + + return null; + } + +function normalizePoints(candidate) { + if (!Array.isArray(candidate)) { + return []; + } + + return candidate + .map(normalizePoint) + .filter(Boolean); +} + +function extractPreviewPoints(item) { + if (!item) { + return []; + } + + const preview = + normalizePoints( + item.track_preview, + ); + + if (preview.length >= 2) { + return preview; + } + + return extractFullDirectPoints( + item, + ); +} + +function extractFullDirectPoints(item) { + if (!item) { + return []; + } + + const candidates = [ + item.points, + item.track_points, + item.coordinates, + ]; + + for (const candidate of candidates) { + const points = + normalizePoints( + candidate, + ); + + if (points.length >= 2) { + return points; + } + } + + const geojson = + item.geojson; + + const coordinates = + geojson?.type === + "LineString" + ? geojson.coordinates + : geojson?.type === + "Feature" && + geojson.geometry?.type === + "LineString" + ? geojson.geometry + .coordinates + : null; + + return normalizePoints( + coordinates, + ); +} + + function simplifyPoints(points, maxPoints = 350) { + if (!Array.isArray(points)) return []; + if (points.length <= maxPoints) return points; + + const step = Math.ceil(points.length / maxPoints); + const output = []; + + for (let index = 0; index < points.length; index += step) { + output.push(points[index]); + } + + const last = points.at(-1); + const outputLast = output.at(-1); + if (!outputLast || outputLast.lat !== last.lat || outputLast.lng !== last.lng) { + output.push(last); + } + + return output; + } + + async function fetchActivityText(filePath) { + const headers = {}; + const token = localStorage.getItem("token"); + if (token) headers.Authorization = `Bearer ${token}`; + + const response = await fetch(filePath, { headers }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} caricando ${filePath}`); + } + return response.text(); + } + +async function fetchFullActivityPoints( + item, +) { + if (!item) { + return []; + } + + const activityId = + item.id; + + if ( + activityId === undefined || + activityId === null || + activityId === "" + ) { + return []; + } + + const token = + localStorage.getItem( + "token", + ) || + sessionStorage.getItem( + "token", + ); + + const headers = {}; + + if (token) { + headers.Authorization = + `Bearer ${token}`; + } + + const response = + await fetch( + `/activities/${encodeURIComponent( + activityId, + )}/route-points`, + { + method: "GET", + + headers, + + cache: "no-store", + + credentials: + "same-origin", + }, + ); + + if (!response.ok) { + let message = + `HTTP ${response.status}`; + + try { + const errorPayload = + await response.json(); + + message = + errorPayload.error || + errorPayload.details || + message; + } catch {} + + throw new Error( + message, + ); + } + + const payload = + await response.json(); + + const rawPoints = + Array.isArray( + payload.points, + ) + ? payload.points + : []; + + const points = + rawPoints + .map(normalizePoint) + .filter(Boolean); + + console.log( + "[activity route] punti completi ricevuti", + { + id: + activityId, + + expected: + payload + .expected_point_count ?? + null, + + received: + points.length, + + format: + payload.format ?? + item.format ?? + null, + }, + ); + + return points; +} + + + function parseTcxPoints(text) { + const xml = new DOMParser().parseFromString(text, "application/xml"); + const parseError = xml.getElementsByTagName("parsererror")[0]; + if (parseError) { + console.warn("[parseTcxPoints] XML parser error", parseError.textContent); + return []; + } + + return Array.from(xml.getElementsByTagName("Trackpoint")) + .map(trackpoint => { + const lat = Number(trackpoint.getElementsByTagName("LatitudeDegrees")[0]?.textContent); + const lng = Number(trackpoint.getElementsByTagName("LongitudeDegrees")[0]?.textContent); + return Number.isFinite(lat) && Number.isFinite(lng) ? { lat, lng } : null; + }) + .filter(Boolean); + } + + function parseGpxPoints(text) { + const xml = new DOMParser().parseFromString(text, "application/xml"); + const parseError = xml.getElementsByTagName("parsererror")[0]; + if (parseError) { + console.warn("[parseGpxPoints] XML parser error", parseError.textContent); + return []; + } + + return Array.from(xml.getElementsByTagName("trkpt")) + .map(point => { + const lat = Number(point.getAttribute("lat")); + const lng = Number(point.getAttribute("lon")); + return Number.isFinite(lat) && Number.isFinite(lng) ? { lat, lng } : null; + }) + .filter(Boolean); + } + +async function getRoutePoints( + item, + options = {}, +) { + if (!item) { + return []; + } + + const full = + options.full === true; + + const baseCacheKey = + item.id || + item.path || + item.name; + + const cacheKey = + baseCacheKey + ? `${full ? "full" : "preview"}:${baseCacheKey}` + : null; + + if ( + cacheKey && + routeCache.has( + cacheKey, + ) + ) { + const cachedPoints = + routeCache.get( + cacheKey, + ); + + console.log( + "[activity route] punti dalla cache", + { + mode: + full + ? "full" + : "preview", + + loaded: + Array.isArray( + cachedPoints, + ) + ? cachedPoints.length + : 0, + + cacheKey, + }, + ); + + return Array.isArray( + cachedPoints, + ) + ? cachedPoints + : []; + } + + const format = + String( + item.format || + item.file_extension || + item.mime_type || + "", + ) + .toLowerCase() + .trim(); + + const lowerPath = + String( + item.path || + item.file_name || + item.name || + "", + ) + .toLowerCase() + .trim(); + + const isFit = + format.includes("fit") || + lowerPath.endsWith( + ".fit", + ); + + const isTcx = + format.includes("tcx") || + lowerPath.endsWith( + ".tcx", + ); + + const isGpx = + format.includes("gpx") || + lowerPath.endsWith( + ".gpx", + ); + + let points = + full + ? extractFullDirectPoints( + item, + ) + : extractPreviewPoints( + item, + ); + + /* + * In modalità full, per i FIT non usa + * track_preview: richiede tutti i punti + * all'endpoint protetto. + */ + if ( + full && + points.length < 2 && + isFit + ) { + points = + await fetchFullActivityPoints( + item, + ); + } + + /* + * GPX e TCX possono essere letti + * direttamente dal file originale. + */ + if ( + full && + points.length < 2 && + item.path && + ( + isTcx || + isGpx + ) + ) { + const text = + await fetchActivityText( + item.path, + ); + + points = + isTcx + ? parseTcxPoints( + text, + ) + : parseGpxPoints( + text, + ); + } + + /* + * Solo le preview vengono ridotte. + * La traccia full rimane completa. + */ + if (!full) { + points = + simplifyPoints( + points, + 350, + ); + } + + if (!Array.isArray(points)) { + points = []; + } + + if (cacheKey) { + routeCache.set( + cacheKey, + points, + ); + } + + console.log( + "[activity route] punti caricati", + { + id: + item.id, + + mode: + full + ? "full" + : "preview", + + format: + item.format || + item.file_extension || + null, + + expected: + Number( + item.point_count, + ) || null, + + loaded: + points.length, + + cacheKey, + }, + ); + + return points; +} + + function createRouteSvg(points) { + const width = 240; + const height = 160; + const padding = 16; + const lats = points.map(point => point.lat); + const lngs = points.map(point => point.lng); + const minLat = Math.min(...lats); + const maxLat = Math.max(...lats); + const minLng = Math.min(...lngs); + const maxLng = Math.max(...lngs); + const latRange = maxLat - minLat || 0.000001; + const lngRange = maxLng - minLng || 0.000001; + const usableWidth = width - padding * 2; + const usableHeight = height - padding * 2; + + const svgPoints = points.map(point => { + const x = padding + ((point.lng - minLng) / lngRange) * usableWidth; + const y = padding + ((maxLat - point.lat) / latRange) * usableHeight; + return { x, y, text: `${x.toFixed(1)},${y.toFixed(1)}` }; + }); + + const pointsAttribute = svgPoints.map(point => point.text).join(" "); + const first = svgPoints[0]; + const last = svgPoints.at(-1); + + return ` + `; + } + + async function renderRouteThumb(container, item) { + const escapeHtml = window.GalleryUtils?.escapeHtml || (value => String(value ?? "")); + + try { + const points = await getRoutePoints(item); + if (points.length < 2) { + container.classList.remove("loading"); + container.classList.add("empty"); + container.innerHTML = `
${escapeHtml(item?.sport || item?.format || "Attività")}
`; + return; + } + + container.innerHTML = createRouteSvg(points); + container.classList.remove("loading"); + container.classList.add("ready"); + } catch (error) { + console.warn("[activity thumb] errore:", item?.name, error); + container.classList.remove("loading"); + container.classList.add("empty"); + container.innerHTML = `
${escapeHtml(item?.sport || item?.format || "Attività")}
`; + } + } + +window.ActivityThumbs = + Object.freeze({ + renderRouteThumb, + getRoutePoints, + fetchFullActivityPoints, + parseTcxPoints, + parseGpxPoints, + createRouteSvg, + }); + + window.renderActivityRouteThumb = renderRouteThumb; + window.fetchFullActivityPoints = fetchFullActivityPoints; + window.getActivityRoutePoints = getRoutePoints; + window.parseTcxPoints = parseTcxPoints; + window.parseGpxPoints = parseGpxPoints; + window.createRouteSvg = createRouteSvg; +})(); diff --git a/public/js/admin/api.js b/public/js/admin/api.js new file mode 100644 index 0000000..3ca6ef5 --- /dev/null +++ b/public/js/admin/api.js @@ -0,0 +1,153 @@ +// ====================================================== +// API CORE REQUEST +// ====================================================== +async function apiRequest(method, path, body = null) { + + if (!Admin.BASE_URL) { + console.error("BASE_URL non ancora inizializzato"); + throw new Error("BASE_URL non inizializzato"); + } + + const options = { + method, + headers: { + "Authorization": "Bearer " + Admin.token, + "Content-Type": "application/json" + } + }; + + if (body) { + options.body = JSON.stringify(body); + } + + const res = await fetch(`${Admin.BASE_URL}${path}`, options); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Errore API ${res.status}: ${text}`); + } + + return res.json(); +} + + + +// ====================================================== +// API GET / POST / DELETE +// ====================================================== +function apiGet(path) { + return apiRequest("GET", path); +} + +function apiPost(path, body = null) { + return apiRequest("POST", path, body); +} + +function apiDelete(path) { + return apiRequest("DELETE", path); +} + + + +// ====================================================== +// ENDPOINT DINAMICO (photos / activities) +// ====================================================== +function apiBase(mode) { + return mode === "activities" ? "/activities" : "/photos"; +} + + + +// ====================================================== +// UTILITY: LEGGI DATA/ORA DAL CAMPO SINCE (foto) +// ====================================================== +function getSinceISO() { + const el = document.getElementById("sinceInput"); + if (!el || !el.value) return null; + return new Date(el.value).toISOString(); +} + +// ====================================================== +// UTILITY: LEGGI DATA/ORA DAL CAMPO SINCE (attività) +// ====================================================== +function getSinceISOActivities() { + const el = document.getElementById("sinceInputActivities"); + if (!el || !el.value) return null; + return new Date(el.value).toISOString(); +} + + + +// ====================================================== +// FOTO — GET DB CHANGES +// ====================================================== +async function getDBChanges(since, user) { + return apiGet(`${apiBase("photos")}/changes?since=${encodeURIComponent(since)}&user=${encodeURIComponent(user)}`); +} + + + +// ====================================================== +// FOTO — GET HARD DELETED +// ====================================================== +async function getHardDeleted(since, user) { + return apiGet(`${apiBase("photos")}/deleted_hard?since=${encodeURIComponent(since)}&user=${encodeURIComponent(user)}`); +} + + + +// ====================================================== +// ATTIVITÀ — GET DB CHANGES +// ====================================================== +async function getActivitiesChanges(since, user) { + return apiGet(`${apiBase("activities")}/changes?since=${encodeURIComponent(since)}&user=${encodeURIComponent(user)}`); +} + + + +// ====================================================== +// ATTIVITÀ — GET HARD DELETED +// ====================================================== +async function getActivitiesHardDeleted(since, user) { + return apiGet(`${apiBase("activities")}/deleted_hard?since=${encodeURIComponent(since)}&user=${encodeURIComponent(user)}`); +} + + + +// ====================================================== +// FOTO — GET BY ID +// ====================================================== +async function getPhotoById(id, user) { + return apiGet(`${apiBase("photos")}/byIds?id=${encodeURIComponent(id)}&user=${encodeURIComponent(user)}`); +} + + + +// ====================================================== +// ATTIVITÀ — GET BY ID +// ====================================================== +async function getActivityById(id, user) { + return apiGet(`${apiBase("activities")}/byIds?id=${encodeURIComponent(id)}&user=${encodeURIComponent(user)}`); +} + + + +// ====================================================== +// EXPORT +// ====================================================== +window.AdminAPI = { + apiGet, + apiPost, + apiDelete, + + getSinceISO, + getSinceISOActivities, + + getDBChanges, + getHardDeleted, + + getActivitiesChanges, + getActivitiesHardDeleted, + + getPhotoById, + getActivityById +}; diff --git a/public/js/admin/cacheSync.js b/public/js/admin/cacheSync.js new file mode 100644 index 0000000..78dba0c --- /dev/null +++ b/public/js/admin/cacheSync.js @@ -0,0 +1,182 @@ +// public/js/admin/cacheSync.js + +function mapById(arr) { + const map = new Map(); + + for (const item of arr || []) { + if (item && item.id != null) { + map.set(String(item.id), item); + } + } + + return map; +} + +function readLocalArray(cacheKey) { + try { + const raw = localStorage.getItem(cacheKey); + const arr = raw ? JSON.parse(raw) : []; + + return Array.isArray(arr) ? arr : []; + } catch (err) { + console.warn(`[AdminCacheSync] errore lettura ${cacheKey}:`, err.message); + return []; + } +} + +function writeLocalArray(cacheKey, arr) { + const safeArr = Array.isArray(arr) ? arr : []; + localStorage.setItem(cacheKey, JSON.stringify(safeArr)); +} + +function removeItemFromLocalCache(cacheKey, id) { + if (id == null) { + return { + ok: false, + reason: "missing id", + cacheKey + }; + } + + const arr = readLocalArray(cacheKey); + const before = arr.length; + + const next = arr.filter(item => String(item.id) !== String(id)); + + writeLocalArray(cacheKey, next); + + return { + ok: true, + cacheKey, + id, + before, + after: next.length, + removed: before - next.length + }; +} + +function removePhotoFromCache(id) { + return removeItemFromLocalCache("photosCache", id); +} + +function removeActivityFromCache(id) { + return removeItemFromLocalCache("activitiesCache", id); +} + +async function progressiveSyncPhotosCacheFromAdmin() { + const lastSync = localStorage.getItem("lastSyncPhotos"); + const localPhotos = readLocalArray("photosCache"); + + // Se non ho cache o lastSync, faccio full load foto + if (!lastSync || localPhotos.length === 0) { + const photos = await apiGet("/photos"); + + writeLocalArray("photosCache", photos); + localStorage.setItem("lastSyncPhotos", new Date().toISOString()); + + return { + mode: "photos", + type: "full", + count: Array.isArray(photos) ? photos.length : 0 + }; + } + + const changed = await apiGet( + `/photos/changes?since=${encodeURIComponent(lastSync)}` + ); + + const hardDeletedRes = await apiGet( + `/photos/deleted_hard?since=${encodeURIComponent(lastSync)}` + ); + + const hardDeleted = hardDeletedRes.deleted || []; + const map = mapById(localPhotos); + + for (const p of changed || []) { + if (p && p.id != null) { + map.set(String(p.id), p); + } + } + + for (const d of hardDeleted || []) { + if (d && d.id != null) { + map.delete(String(d.id)); + } + } + + const merged = Array.from(map.values()); + + writeLocalArray("photosCache", merged); + localStorage.setItem("lastSyncPhotos", new Date().toISOString()); + + return { + mode: "photos", + type: "progressive", + changed: Array.isArray(changed) ? changed.length : 0, + hardDeleted: hardDeleted.length, + count: merged.length + }; +} + +async function progressiveSyncActivitiesCacheFromAdmin() { + const lastSync = localStorage.getItem("lastSyncActivities"); + const localActivities = readLocalArray("activitiesCache"); + + // Se non ho cache o lastSync, faccio full load attività + if (!lastSync || localActivities.length === 0) { + const activities = await apiGet("/activities"); + + writeLocalArray("activitiesCache", activities); + localStorage.setItem("lastSyncActivities", new Date().toISOString()); + + return { + mode: "activities", + type: "full", + count: Array.isArray(activities) ? activities.length : 0 + }; + } + + const changed = await apiGet( + `/activities/changes?since=${encodeURIComponent(lastSync)}` + ); + + const hardDeletedRes = await apiGet( + `/activities/deleted_hard?since=${encodeURIComponent(lastSync)}` + ); + + const hardDeleted = hardDeletedRes.deleted || []; + const map = mapById(localActivities); + + for (const a of changed || []) { + if (a && a.id != null) { + map.set(String(a.id), a); + } + } + + for (const d of hardDeleted || []) { + if (d && d.id != null) { + map.delete(String(d.id)); + } + } + + const merged = Array.from(map.values()); + + writeLocalArray("activitiesCache", merged); + localStorage.setItem("lastSyncActivities", new Date().toISOString()); + + return { + mode: "activities", + type: "progressive", + changed: Array.isArray(changed) ? changed.length : 0, + hardDeleted: hardDeleted.length, + count: merged.length + }; +} + +// Export globale per uso negli altri file admin +window.AdminCacheSync = { + progressiveSyncPhotosCacheFromAdmin, + progressiveSyncActivitiesCacheFromAdmin, + removePhotoFromCache, + removeActivityFromCache +}; diff --git a/public/js/admin/config.js b/public/js/admin/config.js new file mode 100644 index 0000000..324e79a --- /dev/null +++ b/public/js/admin/config.js @@ -0,0 +1,17 @@ +window.Admin = { + BASE_URL: null, + token: localStorage.getItem("token"), + db: [] +}; + +if (!Admin.token) { + window.location.href = "/login"; +} + +async function loadConfig() { + const res = await fetch("/config"); + const cfg = await res.json(); + Admin.BASE_URL = cfg.baseUrl; +} + +document.addEventListener("DOMContentLoaded", loadConfig); diff --git a/public/js/admin/db.js b/public/js/admin/db.js new file mode 100644 index 0000000..7e12a6b --- /dev/null +++ b/public/js/admin/db.js @@ -0,0 +1,388 @@ +// public/js/admin/db.js +// =============================== +// JWT PARSER +// =============================== +function parseJwt(t) { + try { + return JSON.parse(atob(t.split('.')[1])); + } catch { + return {}; + } +} + +// =============================== +// COMPATTA OUTPUT ATTIVITÀ ADMIN +// track_preview viene mostrato solo come numero punti +// =============================== +function compactActivitiesForOutput(data) { + if (!Array.isArray(data)) return data; + + return data.map(a => { + const previewLen = Array.isArray(a.track_preview) + ? a.track_preview.length + : 0; + + return { + ...a, + track_preview: previewLen + }; + }); +} + +// =============================== +// LETTURA DB FOTO — utente corrente +// =============================== +async function readDBUser() { + const photos = await apiGet(`/photos`); + out(photos); +} + +// =============================== +// LETTURA DB FOTO — Admin +// =============================== +async function readDB() { + Admin.db = await apiGet(`/photos?user=Admin`); + out(Admin.db); +} + +// =============================== +// RESET COMPLETO DB FOTO +// =============================== +async function resetDB() { + if (!confirm("Vuoi davvero resettare il DB foto?")) { + return; + } + + await apiPost(`/photos/initDB`); + + localStorage.removeItem("photosCache"); + localStorage.removeItem("lastSyncPhotos"); + + await readDB(); +} + +// =============================== +// RESET DB FOTO PER UTENTE +// =============================== +async function resetDBuser() { + const payload = parseJwt(Admin.token); + let userToReset = payload.name; + + if (payload.name === "Admin") { + const sel = document.getElementById("userSelect"); + + if (!sel || !sel.value) { + alert("Seleziona un utente"); + return; + } + + userToReset = sel.value; + } + + if (!confirm(`Vuoi davvero resettare l'utente "${userToReset}"?`)) { + return; + } + + let path = `/photos/initDBuser`; + + if (payload.name === "Admin") { + path += `?user=${encodeURIComponent(userToReset)}`; + } + + await apiPost(path); + + localStorage.removeItem("photosCache"); + localStorage.removeItem("lastSyncPhotos"); + + await readDB(); + + const outData = await apiGet(`/photos?user=${encodeURIComponent(userToReset)}`); + out(outData); +} + +// =============================== +// CANCELLA FOTO PER ID — HARD DELETE +// =============================== +async function deletePhoto() { + const id = prompt("ID foto da cancellare definitivamente:"); + if (!id) return; + + if (!confirm(`Vuoi davvero cancellare definitivamente la foto "${id}"?`)) { + return; + } + + const data = await apiDelete(`/photos/${id}`); + out(data); + + try { + if (window.AdminCacheSync?.removePhotoFromCache) { + const result = AdminCacheSync.removePhotoFromCache(id); + console.log("[admin delete foto] photosCache aggiornata:", result); + } else { + localStorage.setItem("photosNeedsSync", "1"); + console.warn("[admin delete foto] AdminCacheSync non disponibile, imposto photosNeedsSync"); + } + } catch (err) { + localStorage.setItem("photosNeedsSync", "1"); + console.warn("[admin delete foto] aggiornamento cache fallito:", err.message); + } + + //await readDBUser(); +} + +// =============================== +// TOGGLE SOFT DELETE FOTO PER ID +// =============================== +async function toggleSoftDelete() { + const id = prompt("ID foto da togglare soft delete:"); + if (!id) return; + + const data = await apiPost(`/photos/${id}/toggle-soft`); + out(data); + + try { + if (window.AdminCacheSync?.progressiveSyncPhotosCacheFromAdmin) { + const result = await AdminCacheSync.progressiveSyncPhotosCacheFromAdmin(); + console.log("[admin toggle soft foto] photosCache aggiornata:", result); + } else { + localStorage.setItem("photosNeedsSync", "1"); + console.warn("[admin toggle soft foto] AdminCacheSync non disponibile, imposto photosNeedsSync"); + } + } catch (err) { + localStorage.setItem("photosNeedsSync", "1"); + console.warn("[admin toggle soft foto] sync cache fallita:", err.message); + } + + // await readDBUser(); +} + +// =============================== +// CERCA ID IN index.json — foto +// =============================== +async function findIdIndex() { + const id = prompt("ID da cercare:"); + if (!id) return; + + const data = await apiGet(`/findIdIndex/${id}`); + out(data); +} + +// =============================== +// CERCA FOTO PER ID +// =============================== +async function searchPhotoById() { + const id = prompt("ID foto:"); + if (!id) return; + + const user = parseJwt(Admin.token).name; + + const data = await apiGet(`/photos/byIds?id=${id}&user=${user}`); + out(data); +} + +// =============================== +// MOSTRA CAMBIAMENTI FOTO +// =============================== +async function showChanges() { + const since = document.getElementById("sinceInput").value; + if (!since) return alert("Seleziona una data"); + + const iso = new Date(since).toISOString(); + const user = parseJwt(Admin.token).name; + + const data = await apiGet(`/photos/changes?since=${iso}&user=${user}`); + out(data); +} + +// ====================================================== +// SEZIONE ATTIVITÀ +// ====================================================== + +// =============================== +// LETTURA DB ATTIVITÀ — utente corrente +// =============================== +async function readActivitiesDBUser() { + const activities = await apiGet(`/activities`); + outActivities(compactActivitiesForOutput(activities)); +} + +// =============================== +// LETTURA DB ATTIVITÀ — Admin +// =============================== +async function readActivitiesDB() { + Admin.dbActivities = await apiGet(`/activities?user=Admin`); + outActivities(compactActivitiesForOutput(Admin.dbActivities)); +} + +// =============================== +// RESET COMPLETO DB ATTIVITÀ +// =============================== +async function resetActivitiesDB() { + if (!confirm("Vuoi davvero resettare il DB attività?")) { + return; + } + + await apiPost(`/activities/initDB`); + + localStorage.removeItem("activitiesCache"); + localStorage.removeItem("lastSyncActivities"); + + await readActivitiesDBUser(); +} + +// =============================== +// RESET DB ATTIVITÀ PER UTENTE +// =============================== +async function resetActivitiesDBuser() { + const payload = parseJwt(Admin.token); + let userToReset = payload.name; + + if (payload.name === "Admin") { + const sel = document.getElementById("userSelectActivities"); + + if (!sel || !sel.value) { + alert("Seleziona un utente"); + return; + } + + userToReset = sel.value; + } + + if (!confirm(`Vuoi davvero resettare le attività dell'utente "${userToReset}"?`)) { + return; + } + + if (payload.name === "Admin") { + await apiPost(`/activities/initDBuser?user=${encodeURIComponent(userToReset)}`); + } else { + await apiPost(`/activities/initDB`); + } + + localStorage.removeItem("activitiesCache"); + localStorage.removeItem("lastSyncActivities"); + + const outData = await apiGet(`/activities`); + outActivities(compactActivitiesForOutput(outData)); +} + +// =============================== +// CANCELLA ATTIVITÀ PER ID — HARD DELETE +// =============================== +async function deleteActivity() { + const id = prompt("ID attività da cancellare definitivamente:"); + if (!id) return; + + if (!confirm(`Vuoi davvero cancellare definitivamente l'attività "${id}"?`)) { + return; + } + + const data = await apiDelete(`/activities/${id}`); + outActivities(compactActivitiesForOutput(Array.isArray(data) ? data : [data])); + + try { + if (window.AdminCacheSync?.removeActivityFromCache) { + const result = AdminCacheSync.removeActivityFromCache(id); + console.log("[admin delete attività] activitiesCache aggiornata:", result); + } else { + localStorage.setItem("activitiesNeedsSync", "1"); + console.warn("[admin delete attività] AdminCacheSync non disponibile, imposto activitiesNeedsSync"); + } + } catch (err) { + localStorage.setItem("activitiesNeedsSync", "1"); + console.warn("[admin delete attività] aggiornamento cache fallito:", err.message); + } + + // await readActivitiesDBUser(); +} + +// =============================== +// TOGGLE SOFT DELETE ATTIVITÀ PER ID +// =============================== +async function toggleSoftDeleteActivity() { + const id = prompt("ID attività da togglare soft delete:"); + if (!id) return; + + const data = await apiPost(`/activities/${id}/toggle-soft`); + outActivities(compactActivitiesForOutput(Array.isArray(data) ? data : [data])); + + try { + if (window.AdminCacheSync?.progressiveSyncActivitiesCacheFromAdmin) { + const result = await AdminCacheSync.progressiveSyncActivitiesCacheFromAdmin(); + console.log("[admin toggle soft attività] activitiesCache aggiornata:", result); + } else { + localStorage.setItem("activitiesNeedsSync", "1"); + console.warn("[admin toggle soft attività] AdminCacheSync non disponibile, imposto activitiesNeedsSync"); + } + } catch (err) { + localStorage.setItem("activitiesNeedsSync", "1"); + console.warn("[admin toggle soft attività] sync cache fallita:", err.message); + } + + // await readActivitiesDBUser(); +} + +// =============================== +// CERCA ATTIVITÀ PER ID +// =============================== +async function searchActivityById() { + const id = prompt("ID attività:"); + if (!id) return; + + const user = parseJwt(Admin.token).name; + + const data = await apiGet(`/activities/byIds?id=${id}&user=${user}`); + outActivities(compactActivitiesForOutput(data)); +} + +// =============================== +// MOSTRA CAMBIAMENTI ATTIVITÀ +// =============================== +async function showActivitiesChanges() { + const since = document.getElementById("sinceInputActivities").value; + if (!since) return alert("Seleziona una data"); + + const iso = new Date(since).toISOString(); + const user = parseJwt(Admin.token).name; + + const data = await apiGet(`/activities/changes?since=${iso}&user=${user}`); + outActivities(compactActivitiesForOutput(data)); +} + +// =============================== +// MOSTRA HARD DELETED ATTIVITÀ +// =============================== +async function showActivitiesHardDeleted() { + const since = document.getElementById("sinceInputActivities").value; + if (!since) return alert("Seleziona una data"); + + const iso = new Date(since).toISOString(); + const user = parseJwt(Admin.token).name; + + const data = await apiGet(`/activities/deleted_hard?since=${iso}&user=${user}`); + outActivities(data); +} + +// =============================== +// EXPORT +// =============================== +window.AdminDB = { + readDBUser, + readDB, + resetDB, + resetDBuser, + deletePhoto, + toggleSoftDelete, + findIdIndex, + searchPhotoById, + showChanges, + + readActivitiesDBUser, + readActivitiesDB, + resetActivitiesDB, + resetActivitiesDBuser, + deleteActivity, + toggleSoftDeleteActivity, + searchActivityById, + showActivitiesChanges, + showActivitiesHardDeleted +}; diff --git a/public/js/admin/photoUpload.js b/public/js/admin/photoUpload.js new file mode 100644 index 0000000..185ab7a --- /dev/null +++ b/public/js/admin/photoUpload.js @@ -0,0 +1,395 @@ +(() => { + "use strict"; + + const targetSelect = document.getElementById("photoUploadTarget"); + const folderInput = document.getElementById("photoUploadFolder"); + const uploadButton = document.getElementById("btnUploadPhoto"); + const fileInput = document.getElementById("photoUploadInput"); + const statusBox = document.getElementById("photoUploadStatus"); + + if ( + !targetSelect || + !folderInput || + !uploadButton || + !fileInput || + !statusBox + ) { + console.warn("[photoUpload] Elementi interfaccia upload non trovati"); + + return; + } + + let pendingUpload = null; + let uploading = false; + + function setStatus(message, type = "info") { + statusBox.textContent = message || ""; + statusBox.className = `upload-${type}`; + + if (window.operationBanner && message) { + const bannerType = type === "error" ? "error" : + type === "success" ? "success" : "upload"; + const busy = type === "info" && + /in corso|caricamento destinazioni/i.test(message); + + window.operationBanner.show(message, { + type: bannerType, + busy, + sticky: busy + }); + } + } + + function normalize(value) { + return String(value ?? "").trim(); + } + + function getAuthorizationHeaders() { + /* + * Se l'autenticazione utilizza cookie/sessione, + * non serve aggiungere Authorization. + * + * Se utilizzi JWT in localStorage, modifica i nomi + * qui sotto in base al tuo progetto. + */ + const token = + localStorage.getItem("token") || + localStorage.getItem("jwtToken") || + localStorage.getItem("accessToken"); + + if (!token) { + return {}; + } + + return { + Authorization: `Bearer ${token}`, + }; + } + + async function readJsonResponse(response) { + const contentType = response.headers.get("content-type") || ""; + + if (contentType.includes("application/json")) { + return response.json(); + } + + const text = await response.text(); + + return { + ok: false, + error: text || `Errore HTTP ${response.status}`, + }; + } + + function setUploadEnabled() { + const hasTarget = Boolean(normalize(targetSelect.value)); + + const hasFolder = Boolean(normalize(folderInput.value)); + + uploadButton.disabled = uploading || !hasTarget || !hasFolder; + } + + async function loadUploadTargets() { + targetSelect.disabled = true; + uploadButton.disabled = true; + + targetSelect.innerHTML = + ''; + + // Il caricamento iniziale non deve mostrare un banner. + + try { + const response = await fetch("/photos/upload-targets", { + method: "GET", + credentials: "include", + headers: { + Accept: "application/json", + ...getAuthorizationHeaders(), + }, + }); + + const result = await readJsonResponse(response); + + if (!response.ok || !result.ok) { + throw new Error( + result.error || + `Impossibile caricare le destinazioni (${response.status})`, + ); + } + + const targets = Array.isArray(result.targets) ? result.targets : []; + + targetSelect.replaceChildren(); + + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Seleziona utente o device"; + targetSelect.appendChild(placeholder); + + for (const target of targets) { + const value = normalize(target.value); + + if (!value) { + continue; + } + + const option = document.createElement("option"); + + option.value = value; + option.textContent = + target.type === "device" + ? `${target.label || value} (device)` + : `${target.label || value} (utente)`; + + option.dataset.targetType = target.type || ""; + targetSelect.appendChild(option); + } + + targetSelect.disabled = targets.length === 0; + + if (targets.length === 0) { + setStatus("Nessuna destinazione autorizzata per l'upload.", "error"); + } else { + // Nessun banner automatico all’apertura della pagina. + } + + setUploadEnabled(); + } catch (error) { + console.error("[photoUpload] Errore upload-targets:", error); + + targetSelect.innerHTML = + ''; + + targetSelect.disabled = true; + uploadButton.disabled = true; + + setStatus(error.message, "error"); + } + } + + uploadButton.addEventListener("click", () => { + if (uploading) { + return; + } + + const source = normalize(targetSelect.value); + const folder = normalize(folderInput.value); + + if (!source) { + setStatus("Seleziona prima l'utente o il device.", "error"); + + return; + } + + if (!folder) { + setStatus("Inserisci prima il nome della cartella.", "error"); + + folderInput.focus(); + return; + } + + if ( + folder === "." || + folder === ".." || + folder.includes("/") || + folder.includes("\\") || + folder.includes("\0") + ) { + setStatus("Nome cartella non valido. Non usare /, \\ oppure ..", "error"); + + folderInput.focus(); + return; + } + + pendingUpload = { + source, + folder, + }; + + fileInput.value = ""; + + setStatus( + `Destinazione: ${source}\n` + + `Cartella: ${folder}\n` + + "Apertura del selettore file...", + "info", + ); + + /* + * Deve essere chiamato direttamente nel gestore del click. + * Non inserire await, setTimeout o prompt prima di questo punto. + */ + fileInput.click(); + }); + + fileInput.addEventListener("change", async () => { + const files = Array.from(fileInput.files || []); + + console.log( + "[photoUpload] File selezionati:", + files.map((file) => ({ + name: file.name, + type: file.type, + size: file.size, + })), + ); + + if (files.length === 0) { + pendingUpload = null; + + setStatus("Nessun file selezionato.", "error"); + + return; + } + + if (!pendingUpload) { + setStatus( + "Destinazione upload non disponibile. Ripeti la selezione.", + "error", + ); + + fileInput.value = ""; + return; + } + + const { source, folder } = pendingUpload; + + pendingUpload = null; + uploading = true; + + targetSelect.disabled = true; + folderInput.disabled = true; + setUploadEnabled(); + + const totalSelectedBytes = files.reduce( + (total, file) => total + file.size, + 0, + ); + + const totalSelectedMb = (totalSelectedBytes / 1024 / 1024).toFixed(1); + + setStatus( + `Upload di ${files.length} file in corso...\n` + + `Dimensione totale: ${totalSelectedMb} MB\n` + + `Destinazione: ${source}\n` + + `Cartella: ${folder}`, + "info", + ); + + try { + const formData = new FormData(); + + formData.append("source", source); + formData.append("folder", folder); + + /* + * Tutti i file usano il campo "photo". + * Il server li riceve con: + * uploadMedia.array("photo", MEDIA_UPLOAD_MAX_FILES) + */ + for (const file of files) { + formData.append("photo", file, file.name); + } + + console.log("[photoUpload] Invio richiesta", { + source, + folder, + fileCount: files.length, + totalSelectedBytes, + }); + + const response = await fetch("/photos/upload", { + method: "POST", + credentials: "include", + + /* + * Non aggiungere Content-Type. + * Il browser genera automaticamente il boundary + * multipart/form-data. + */ + headers: { + Accept: "application/json", + ...getAuthorizationHeaders(), + }, + + body: formData, + }); + + const result = await readJsonResponse(response); + + console.log("[photoUpload] Risposta server:", { + status: response.status, + result, + }); + + if (!response.ok || !result.ok) { + throw new Error( + result.error || `Upload non riuscito (${response.status})`, + ); + } + + const returnedFiles = Array.isArray(result.files) ? result.files : []; + + const renamedFiles = returnedFiles.filter((file) => { + return file.renamed; + }); + + let message = + "Upload completato correttamente.\n" + + `File caricati: ${result.fileCount ?? returnedFiles.length}\n` + + `Foto: ${result.photoCount ?? 0}\n` + + `Video: ${result.videoCount ?? 0}\n` + + `Destinazione: ${result.source ?? source}\n` + + `Cartella: ${result.folder ?? folder}`; + + if (renamedFiles.length > 0) { + message += + "\nFile rinominati perché già esistenti: " + renamedFiles.length; + + for (const file of renamedFiles) { + message += `\n${file.originalFileName} -> ${file.fileName}`; + } + } + + if (result.watcherPending) { + message += "\nIl watcher elaborerà automaticamente tutti i media."; + } + + setStatus(message, "success"); + } catch (error) { + console.error("[photoUpload] Errore upload:", error); + + setStatus(`Upload fallito: ${error.message}`, "error"); + } finally { + uploading = false; + + targetSelect.disabled = false; + folderInput.disabled = false; + + /* + * Permette di selezionare nuovamente gli stessi file. + */ + fileInput.value = ""; + + setUploadEnabled(); + } + }); + + folderInput.addEventListener("input", () => { + setUploadEnabled(); + }); + + targetSelect.addEventListener("change", () => { + setUploadEnabled(); + + const source = normalize(targetSelect.value); + + if (source) { + setStatus(`Destinazione selezionata: ${source}`, "info"); + } else { + setStatus("Seleziona l'utente o il device di destinazione.", "info"); + } + }); + + loadUploadTargets(); +})(); diff --git a/public/js/admin/scan.js b/public/js/admin/scan.js new file mode 100644 index 0000000..d056ef7 --- /dev/null +++ b/public/js/admin/scan.js @@ -0,0 +1,331 @@ +let photoScanInterval = null; +let activityScanInterval = null; + +/* ============================================================ + UTILS + ============================================================ */ +function stopPhotoScanPolling() { + if (photoScanInterval) { + clearInterval(photoScanInterval); + photoScanInterval = null; + } +} + +function stopActivityScanPolling() { + if (activityScanInterval) { + clearInterval(activityScanInterval); + activityScanInterval = null; + } +} + +function updateScanUi(data) { + const current = Number(data?.current || 0); + const total = Number(data?.total || 0); + + const percent = Number.isFinite(Number(data?.percent)) + ? Number(data.percent) + : total > 0 + ? Math.round((current / total) * 100) + : 0; + + return { current, total, percent }; +} + +/* Aggiornamento indipendente dei banner di scansione. */ +function showPhotoBanner(message, options = {}) { + if (window.operationBanner) { + window.operationBanner.show(message, { ...options, id: "photos" }); + } +} + +function showActivityBanner(message, options = {}) { + if (window.operationBanner) { + window.operationBanner.show(message, { ...options, id: "activities" }); + } +} + +/* ============================================================ + FOTO — SCANSIONE + ============================================================ */ +let photoScanRequestDone = false; +let photoCacheSynced = false; +let photoPollingBusy = false; + +async function scan() { + photoScanRequestDone = false; + photoCacheSynced = false; + + startScanStatusPolling(); + + if (window.operationBanner) { + showPhotoBanner( + "Avvio scansione foto…", + { type: "info", busy: true, sticky: true } + ); + } + + try { + await apiGet("/scan"); + photoScanRequestDone = true; + } catch (err) { + photoScanRequestDone = true; + console.error("Errore avvio scan foto:", err); + out?.({ error: err.message }); + + if (window.operationBanner) { + showPhotoBanner( + `Errore nella scansione foto: ${err.message}`, + { type: "error", sticky: true } + ); + } + } +} + +async function startScanStatusPolling() { + stopPhotoScanPolling(); + + photoScanInterval = setInterval(async () => { + if (photoPollingBusy) return; + photoPollingBusy = true; + + try { + const data = await apiGet(`/scanStatus/photos?ts=${Date.now()}`); + const remaining = Number(data?.remaining ?? 0); + const pendingCount = Number(data?.pending || 0); + const processingCount = Number(data?.processing || 0); + const errorsFromDb = Number(data?.errors || 0); + const current = Number(data?.current || 0); + const total = Number(data?.total || 0); + const percent = Math.max(0, Math.min(100, Number(data?.percent || 0))); + if (window.operationBanner) { + window.operationBanner.setProgress("photos", percent); + } + + let queue = null; + try { + queue = await apiGet(`/scanStatus/worker-queues?ts=${Date.now()}`); + if (!queue?.ok) queue = null; + } catch (err) { + console.warn("[scan foto] lettura coda fallita:", err.message); + } + + const pending = Number(queue?.photos || 0); + const errors = Number(queue?.photosErrors || 0); + const scanFinished = photoScanRequestDone; + + if (window.operationBanner) { + let message; + + if (!scanFinished) { + message = + `Scansione foto in corso · ${pending} da elaborare · ${errors} errori`; + showPhotoBanner(message, { + type: errors > 0 ? "error" : "info", + busy: true, + sticky: true + }); + } else if (!photoCacheSynced) { + message = + `Accodamento completato · ${pending} foto da elaborare · ${errors} errori`; + showPhotoBanner(message, { + type: errors > 0 ? "error" : "info", + busy: pending > 0, + sticky: true + }); + } else if (pending > 0) { + showPhotoBanner( + `Elaborazione foto in corso: ${pending} da elaborare · ${errors} errori`, + { type: errors > 0 ? "error" : "info", busy: true, sticky: true } + ); + } else { + stopPhotoScanPolling(); + + showPhotoBanner( + errors > 0 + ? `Elaborazione terminata con ${errors} errori nella coda foto` + : "Scansione ed elaborazione foto completate", + { + type: errors > 0 ? "error" : "success", + busy: false, + sticky: errors > 0, + duration: 5000 + } + ); + } + } + + if (scanFinished && !photoCacheSynced) { + if (window.AdminCacheSync?.progressiveSyncPhotosCacheFromAdmin) { + const result = + await AdminCacheSync.progressiveSyncPhotosCacheFromAdmin(); + console.log("[admin scan foto] cache aggiornata:", result); + } else { + console.warn("[admin scan foto] AdminCacheSync non disponibile"); + } + + await readDBUser(); + photoCacheSynced = true; + } + + if (scanFinished && !queue) { + if (window.operationBanner) { + showPhotoBanner( + "Scansione terminata; impossibile leggere lo stato della coda foto", + { type: "error", sticky: true } + ); + } + } + } catch (err) { + console.warn("[scan foto] aggiornamento stato fallito:", err.message); + } finally { + photoPollingBusy = false; + } + }, 1000); +} + +/* ============================================================ + ATTIVITÀ — SCANSIONE ED ELABORAZIONE CODA + ============================================================ */ +let activityScanRequestDone = false; +let activityCacheSynced = false; +let activityPollingBusy = false; + +async function scanActivities() { + activityScanRequestDone = false; + activityCacheSynced = false; + + if (window.operationBanner) { + showActivityBanner( + "Avvio scansione attività…", + { type: "info", busy: true, sticky: true } + ); + } + + startScanActivitiesPolling(); + + try { + await apiGet("/scanActivities"); + activityScanRequestDone = true; + } catch (err) { + activityScanRequestDone = true; + console.error("Errore avvio scan attività:", err); + outActivities?.({ error: err.message }); + stopActivityScanPolling(); + + if (window.operationBanner) { + showActivityBanner( + `Errore nella scansione attività: ${err.message}`, + { type: "error", sticky: true } + ); + } + } +} + +function startScanActivitiesPolling() { + stopActivityScanPolling(); + + activityScanInterval = setInterval(async () => { + if (activityPollingBusy) return; + activityPollingBusy = true; + + try { + const data = await apiGet( + `/scanStatus/activities?ts=${Date.now()}` + ); + + const { current, total, percent } = updateScanUi(data); + if (window.operationBanner) { + window.operationBanner.setProgress("activities", percent); + } + + let queue = null; + try { + queue = await apiGet( + `/scanStatus/worker-queues?ts=${Date.now()}` + ); + if (!queue?.ok) queue = null; + } catch (err) { + console.warn( + "[scan attività] lettura coda fallita:", + err.message + ); + } + + const pending = Number(queue?.activities || 0); + const errors = Number(queue?.activitiesErrors || 0); + + const scanFinished = + activityScanRequestDone && + (total === 0 || current >= total); + + // Aggiorna la cache una sola volta, quando la scansione è finita. + if (scanFinished && !activityCacheSynced) { + if (window.AdminCacheSync?.progressiveSyncActivitiesCacheFromAdmin) { + const result = + await AdminCacheSync.progressiveSyncActivitiesCacheFromAdmin(); + console.log("[admin scan attività] cache aggiornata:", result); + } else { + console.warn( + "[admin scan attività] AdminCacheSync non disponibile" + ); + } + + await readActivitiesDBUser(); + activityCacheSynced = true; + } + + if (!window.operationBanner) { + // Senza banner non serve aggiornare messaggi, ma il polling + // continua comunque fino alla fine della coda. + if (scanFinished && queue && pending === 0) { + stopActivityScanPolling(); + } + return; + } + + if (!scanFinished) { + showActivityBanner( + `Scansione attività: ${percent}%` + + (queue + ? ` · ${pending} in coda/elaborazione · ${errors} errori` + : ""), + { type: errors > 0 ? "error" : "info", busy: true, sticky: true } + ); + } else if (!queue) { + // Non nascondere il banner se non possiamo verificare la coda. + showActivityBanner( + "Scansione attività terminata; attendo di poter verificare la coda", + { type: "error", busy: true, sticky: true } + ); + } else if (pending > 0) { + showActivityBanner( + `Elaborazione attività in corso: ${pending} in coda/elaborazione` + + (errors > 0 ? ` · ${errors} errori` : ""), + { type: errors > 0 ? "error" : "info", busy: true, sticky: true } + ); + } else { + // La scansione è terminata e non ci sono job pending/processing. + stopActivityScanPolling(); + + showActivityBanner( + errors > 0 + ? `Scansione attività completata con ${errors} errori` + : "Scansione attività completata", + { + type: errors > 0 ? "error" : "success", + busy: false, + sticky: errors > 0, + duration: 5000 + } + ); + } + } catch (err) { + console.warn( + "[scan attività] aggiornamento stato fallito, riprovo:", + err.message + ); + } finally { + activityPollingBusy = false; + } + }, 1000); +} diff --git a/public/js/admin/ui.js b/public/js/admin/ui.js new file mode 100644 index 0000000..311ccc5 --- /dev/null +++ b/public/js/admin/ui.js @@ -0,0 +1,174 @@ +// =============================== +// UI FUNCTIONS +// =============================== + +// Output foto →
+function out(data) {
+  const el = document.getElementById("out");
+  if (el) {
+    el.textContent = JSON.stringify(data, null, 2);
+  }
+}
+
+// Output attività → 
+function outActivities(data) {
+  const el = document.getElementById("outActivities");
+  if (el) {
+    el.textContent = JSON.stringify(data, null, 2);
+  }
+}
+
+// ===============================
+//  CARICA LISTA UTENTI — solo Admin
+// ===============================
+async function loadUserDropdown() {
+  try {
+    const users = await apiGet(`/users`);
+
+    // FOTO
+    const selPhotos = document.getElementById("userSelect");
+    if (selPhotos) {
+      selPhotos.innerHTML = "";
+
+      users.forEach(u => {
+        const opt = document.createElement("option");
+        opt.value = u.name;
+        opt.textContent = u.name;
+        selPhotos.appendChild(opt);
+      });
+    }
+
+    // ATTIVITÀ
+    const selActivities = document.getElementById("userSelectActivities");
+    if (selActivities) {
+      selActivities.innerHTML = "";
+
+      users.forEach(u => {
+        const opt = document.createElement("option");
+        opt.value = u.name;
+        opt.textContent = u.name;
+        selActivities.appendChild(opt);
+      });
+    }
+
+  } catch (err) {
+    console.error("Errore nel caricare gli utenti:", err);
+    out({
+      error: "Errore nel caricare gli utenti",
+      details: err.message
+    });
+  }
+}
+
+// ===============================
+//  INIZIALIZZAZIONE UI
+// ===============================
+document.addEventListener("DOMContentLoaded", async () => {
+  // 1. Aspetta che config.js carichi Admin.BASE_URL
+  await loadConfig();
+
+  // 2. Helper per bind
+  const bind = (id, fn) => {
+    const el = document.getElementById(id);
+
+    if (el && typeof fn === "function") {
+      el.onclick = fn;
+    }
+  };
+
+  // ===============================
+  // FOTO
+  // ===============================
+  bind("btnScan", scan);
+  bind("btnResetDB", resetDB);
+  bind("btnReadDBUser", readDBUser);
+  bind("btnDeletePhoto", deletePhoto);
+  bind("btnFindIdIndex", findIdIndex);
+  bind("btnResetDBuser", resetDBuser);
+  bind("btnSearchPhotoById", searchPhotoById);
+
+  // Toggle soft delete foto
+  bind("btnToggleSoft", toggleSoftDelete);
+
+  bind("btnShowDBChanges", async () => {
+    const since = getSinceISO();
+
+    if (!since) {
+      return out({ error: "Inserisci una data/ora" });
+    }
+
+    const payload = parseJwt(Admin.token);
+    const user = payload.name;
+    const res = await getDBChanges(since, user);
+
+    out(res);
+  });
+
+  bind("btnShowHardDeleted", async () => {
+    const since = getSinceISO();
+
+    if (!since) {
+      return out({ error: "Inserisci una data/ora" });
+    }
+
+    const payload = parseJwt(Admin.token);
+    const user = payload.name;
+    const res = await getHardDeleted(since, user);
+
+    out(res.deleted || res);
+  });
+
+  bind("btnBack", () => {
+    window.location.href = "index.html";
+  });
+
+  // ===============================
+  // ATTIVITÀ
+  // ===============================
+  bind("btnScanActivities", scanActivities);
+  bind("btnResetActivitiesDB", resetActivitiesDB);
+  bind("btnReadActivitiesDB", readActivitiesDBUser);
+  bind("btnDeleteActivity", deleteActivity);
+
+  // Toggle soft delete attività
+  bind("btnToggleSoftActivity", toggleSoftDeleteActivity);
+
+  bind("btnSearchActivityById", searchActivityById);
+
+  bind("btnShowActivitiesChanges", async () => {
+    const since = getSinceISOActivities();
+
+    if (!since) {
+      return outActivities({ error: "Inserisci una data/ora" });
+    }
+
+    const payload = parseJwt(Admin.token);
+    const user = payload.name;
+    const res = await getActivitiesChanges(since, user);
+
+    outActivities(res);
+  });
+
+  bind("btnShowActivitiesHardDeleted", async () => {
+    const since = getSinceISOActivities();
+
+    if (!since) {
+      return outActivities({ error: "Inserisci una data/ora" });
+    }
+
+    const payload = parseJwt(Admin.token);
+    const user = payload.name;
+    const res = await getActivitiesHardDeleted(since, user);
+
+    outActivities(res.deleted || res);
+  });
+
+  // ===============================
+  // DROPDOWN UTENTI — solo Admin
+  // ===============================
+  const payload = parseJwt(Admin.token);
+
+  if (payload.name === "Admin") {
+    loadUserDropdown();
+  }
+});
diff --git a/public/js/api.js b/public/js/api.js
new file mode 100644
index 0000000..61ca87d
--- /dev/null
+++ b/public/js/api.js
@@ -0,0 +1,111 @@
+window.AppConfig = window.AppConfig || { ready: false };
+
+// ===============================================
+// WAIT FOR CONFIG
+// ===============================================
+function waitForConfig() {
+  return new Promise(resolve => {
+    if (!window.AppConfig) {
+      window.AppConfig = { ready: false };
+    }
+
+    if (window.AppConfig.ready) {
+      return resolve();
+    }
+
+    window.addEventListener("config:ready", resolve, { once: true });
+  });
+}
+
+// ===============================================
+// API WRAPPER
+// ===============================================
+async function apiGet(url) {
+  const token = localStorage.getItem("token");
+
+  const res = await fetch(url, {
+    headers: { "Authorization": "Bearer " + token }
+  });
+
+  const text = await res.text();
+  try {
+    return JSON.parse(text);
+  } catch {
+    console.error("❌ Risposta non JSON:", text);
+    throw new Error("Risposta non valida dal server");
+  }
+}
+
+// ===============================================
+// ENDPOINT DINAMICO (photos / activities)
+// ===============================================
+function apiBase() {
+  return state.mode === "activities" ? "/activities" : "/photos";
+}
+
+// ===============================================
+// GET ALL ITEMS (FULL LOAD)
+// ===============================================
+async function getAllItems(user) {
+  await waitForConfig();
+  return apiGet(`${apiBase()}?user=${encodeURIComponent(user)}`);
+}
+
+// ===============================================
+// GET ITEM BY ID
+// ===============================================
+async function getItemById(id) {
+  await waitForConfig();
+
+  const payload = parseJwt(localStorage.getItem("token"));
+  const user = payload?.name || "Common";
+
+  return apiGet(
+    `${apiBase()}/byIds?id=${encodeURIComponent(id)}&user=${encodeURIComponent(user)}`
+  );
+}
+
+// ===============================================
+// GET CHANGES (INCREMENTAL SYNC)
+// ===============================================
+async function getChanges(since, user) {
+  await waitForConfig();
+
+  return apiGet(
+    `${apiBase()}/changes?since=${encodeURIComponent(since)}&user=${encodeURIComponent(user)}`
+  );
+}
+
+// ===============================================
+// GET HARD DELETES
+// ===============================================
+async function getDeletedHard(since, user) {
+  await waitForConfig();
+
+  const json = await apiGet(
+    `${apiBase()}/deleted_hard?since=${encodeURIComponent(since)}&user=${encodeURIComponent(user)}`
+  );
+
+  return json.deleted || [];
+}
+
+// ===============================================
+// FETCH ITEMS BY IDS (bulk)
+// ===============================================
+async function fetchItemsByIds(ids) {
+  if (!ids || !ids.length) return [];
+
+  const qs = ids.map(id => `id=${encodeURIComponent(id)}`).join("&");
+
+  const payload = parseJwt(localStorage.getItem("token"));
+  const user = payload?.name || "Common";
+
+  return apiGet(`${apiBase()}/byIds?${qs}&user=${encodeURIComponent(user)}`);
+}
+
+// EXPORT
+window.getAllItems = getAllItems;
+window.getItemById = getItemById;
+window.getChanges = getChanges;
+window.getDeletedHard = getDeletedHard;
+window.fetchItemsByIds = fetchItemsByIds;
diff --git a/public/js/auth.js b/public/js/auth.js
new file mode 100644
index 0000000..b2acf1e
--- /dev/null
+++ b/public/js/auth.js
@@ -0,0 +1,225 @@
+// ===============================
+// AUTH.JS
+// Login, JWT, redirect e auto logout
+// ===============================
+
+// Deve rimanere globale se viene usata da altri file
+function parseJwt(token) {
+  try {
+    const payload = token.split(".")[1];
+
+    if (!payload) return null;
+
+    const base64 = payload
+      .replace(/-/g, "+")
+      .replace(/_/g, "/")
+      .padEnd(Math.ceil(payload.length / 4) * 4, "=");
+
+    const json = decodeURIComponent(
+      atob(base64)
+        .split("")
+        .map((char) =>
+          `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`
+        )
+        .join("")
+    );
+
+    return JSON.parse(json);
+  } catch (error) {
+    console.error("[AUTH] JWT non valido:", error);
+    return null;
+  }
+}
+
+(() => {
+  "use strict";
+
+  const LOGIN_ENDPOINT = "/auth/login";
+  const LOGIN_PAGE = "/login";
+  const TOKEN_KEY = "token";
+
+  let autoLogoutTimer = null;
+
+  // -------------------------------
+  // TOKEN
+  // -------------------------------
+
+  function saveToken(token) {
+    try {
+      localStorage.setItem(TOKEN_KEY, token);
+    } catch (error) {
+      console.error("[AUTH] Errore salvataggio token:", error);
+    }
+  }
+
+  function getToken() {
+    try {
+      return localStorage.getItem(TOKEN_KEY) || "";
+    } catch {
+      return "";
+    }
+  }
+
+  function clearToken() {
+    clearTimeout(autoLogoutTimer);
+    autoLogoutTimer = null;
+
+    try {
+      localStorage.removeItem(TOKEN_KEY);
+    } catch (error) {
+      console.error("[AUTH] Errore rimozione token:", error);
+    }
+  }
+
+  // -------------------------------
+  // LOGIN
+  // -------------------------------
+
+  async function login(email, password) {
+    const response = await fetch(LOGIN_ENDPOINT, {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+      },
+      body: JSON.stringify({ email, password }),
+    });
+
+    if (!response.ok) {
+      const message = await response.text();
+      throw new Error(message || "Credenziali non valide");
+    }
+
+    const data = await response.json();
+
+    if (!data?.token) {
+      throw new Error("Token mancante nella risposta");
+    }
+
+    saveToken(data.token);
+    scheduleAutoLogout();
+
+    window.dispatchEvent(
+      new CustomEvent("login:success", {
+        detail: data,
+      })
+    );
+
+    return data;
+  }
+
+  // -------------------------------
+  // VERIFICA AUTENTICAZIONE
+  // -------------------------------
+
+  function isLoggedIn() {
+    const token = getToken();
+    const payload = token ? parseJwt(token) : null;
+
+    if (!payload?.exp) {
+      return false;
+    }
+
+    const valid = payload.exp * 1000 > Date.now();
+
+    console.log("[AUTH CHECK]", {
+      valid,
+      user: payload.name,
+      exp: new Date(payload.exp * 1000).toISOString(),
+    });
+
+    return valid;
+  }
+
+  function getUser() {
+    const token = getToken();
+    return token ? parseJwt(token) : null;
+  }
+
+  function requireAuth(redirectUrl = LOGIN_PAGE) {
+    if (isLoggedIn()) {
+      return true;
+    }
+
+    clearToken();
+
+    const currentPath =
+      window.location.pathname +
+      window.location.search +
+      window.location.hash;
+
+    const separator = redirectUrl.includes("?") ? "&" : "?";
+
+    window.location.replace(
+      `${redirectUrl}${separator}redirect=${encodeURIComponent(currentPath)}`
+    );
+
+    return false;
+  }
+
+  // -------------------------------
+  // AUTO LOGOUT
+  // -------------------------------
+
+  function scheduleAutoLogout() {
+    clearTimeout(autoLogoutTimer);
+    autoLogoutTimer = null;
+
+    const payload = getUser();
+    const expiresAt = payload?.exp ? payload.exp * 1000 : 0;
+    const timeout = expiresAt - Date.now();
+
+    if (!expiresAt) {
+      return;
+    }
+
+    if (timeout <= 0) {
+      logout({ redirect: true });
+      return;
+    }
+
+    autoLogoutTimer = setTimeout(() => {
+      logout({ redirect: true });
+    }, timeout);
+  }
+
+  function logout(options = {}) {
+    clearToken();
+
+    if (window.AppAuthLogout?.logout) {
+      return window.AppAuthLogout.logout(options);
+    }
+
+    if (options.redirect !== false) {
+      window.location.replace(LOGIN_PAGE);
+    }
+  }
+
+  // -------------------------------
+  // INIT
+  // -------------------------------
+
+  function init() {
+    if (isLoggedIn()) {
+      scheduleAutoLogout();
+    }
+  }
+
+  // -------------------------------
+  // API GLOBALE
+  // -------------------------------
+
+  window.AppAuth = Object.freeze({
+    login,
+    logout,
+    isLoggedIn,
+    getUser,
+    requireAuth,
+    scheduleAutoLogout,
+  });
+
+  if (document.readyState === "loading") {
+    document.addEventListener("DOMContentLoaded", init, { once: true });
+  } else {
+    init();
+  }
+})();
diff --git a/public/js/bottomSheet.js b/public/js/bottomSheet.js
new file mode 100644
index 0000000..d003aa7
--- /dev/null
+++ b/public/js/bottomSheet.js
@@ -0,0 +1,265 @@
+// ===============================
+// BOTTOM SHEET — foto + attività
+// ===============================
+
+const bottomSheet = document.getElementById("bottomSheet");
+const sheetGallery = document.getElementById("sheetGallery");
+let optionsSheetRef = document.getElementById("optionsSheet");
+
+// Overlay difensivo
+let sheetOverlay = document.getElementById("sheetOverlay");
+if (!sheetOverlay) {
+  sheetOverlay = document.createElement("div");
+  sheetOverlay.id = "sheetOverlay";
+  sheetOverlay.className = "sheet-overlay";
+  document.body.appendChild(sheetOverlay);
+}
+
+// ===============================
+// APRI BOTTOM SHEET (foto + attività)
+// ===============================
+function openBottomSheet(itemList) {
+  const list = Array.isArray(itemList) ? itemList : [];
+
+  // 0 o 1 elemento → apri modal direttamente
+  if (list.length <= 1) {
+    const item = list[0];
+    if (item) {
+      closeBottomSheet();
+
+      if (state.mode === "photos") {
+        window.openModalFromList?.([item], 0);
+      } else {
+        window.openActivityModal?.(item);
+      }
+    }
+    return;
+  }
+
+  // 2+ elementi → strip
+  sheetGallery.innerHTML = "";
+
+  list.forEach((item, index) => {
+    const div = document.createElement("div");
+    div.className = "sheet-item";
+    div.tabIndex = 0;
+
+    // ===============================
+    // FOTO
+    // ===============================
+    if (state.mode === "photos") {
+      const { preview } = mediaUrlsFromPhoto(item);
+
+      const img = document.createElement("img");
+      img.className = "sheet-thumb";
+      img.src = preview;
+      img.alt = item?.name || "";
+      img.loading = "lazy";
+
+      const openFromIndex = () => {
+        closeBottomSheet();
+        window.openModalFromList?.(list, index);
+      };
+
+      div.addEventListener("click", openFromIndex);
+      div.addEventListener("keydown", (e) => {
+        if (e.key === "Enter" || e.key === " ") {
+          e.preventDefault();
+          openFromIndex();
+        }
+      });
+
+      div.appendChild(img);
+    }
+
+    // ===============================
+    // ATTIVITÀ
+    // ===============================
+else {
+  div.classList.add("activity-sheet-item");
+
+  const routeBox = document.createElement("div");
+  routeBox.className = "activity-sheet-route";
+
+  routeBox.innerHTML = `
+    
+ ... +
+ `; + + window.renderActivityRouteThumb?.(routeBox, item); + + /*const label = document.createElement("div"); + label.className = "activity-sheet-label"; + label.textContent = item.display_name || "Attività"; +*/ +const label = document.createElement("div"); +label.className = "activity-sheet-label"; +label.textContent = + item.display_name || + item.route_name || + item.title || + item.name || + "Attività"; + + const openFromIndex = () => { + closeBottomSheet(); + window.openActivityModal?.(item, list, index); + }; + + div.addEventListener("click", openFromIndex); + + div.appendChild(routeBox); + div.appendChild(label); +} + + + sheetGallery.appendChild(div); +}); + +bottomSheet.classList.add("open"); +sheetOverlay.classList.add("open"); + +} + +// =============================== +// AGGIORNA CONTENUTO BOTTOM SHEET +// =============================== +function refreshBottomSheet(itemList) { + const list = Array.isArray(itemList) ? itemList : []; + + if (!bottomSheet.classList.contains("open")) { + return; + } + + sheetGallery.innerHTML = ""; + + list.forEach((item, index) => { + + const div = document.createElement("div"); + div.className = "sheet-item"; + div.tabIndex = 0; + + // FOTO + if (state.mode === "photos") { + + const { preview } = mediaUrlsFromPhoto(item); + + const img = document.createElement("img"); + img.className = "sheet-thumb"; + img.src = preview; + img.alt = item?.name || ""; + img.loading = "lazy"; + + div.appendChild(img); + + div.onclick = () => { + closeBottomSheet(); + window.openModalFromList?.(list, index); + }; + + } + + // ATTIVITÀ + else { + + div.classList.add("activity-sheet-item"); + + const routeBox = document.createElement("div"); + routeBox.className = "activity-sheet-route"; + + routeBox.innerHTML = ` +
+ ... +
+ `; + + window.renderActivityRouteThumb?.(routeBox, item); + + const label = document.createElement("div"); + label.className = "activity-sheet-label"; + label.textContent = + item.display_name || + item.route_name || + item.title || + item.name || + "Attività"; + + div.appendChild(routeBox); + div.appendChild(label); + + div.onclick = () => { + closeBottomSheet(); + window.openActivityModal?.(item, list, index); + }; + } + + sheetGallery.appendChild(div); + }); +} + +// =============================== +// CHIUDI BOTTOM SHEET +// =============================== +function closeBottomSheet() { + bottomSheet.classList.remove("open"); + sheetOverlay.classList.remove("open"); +} + +// =============================== +// OPTIONS SHEET (versione bottomSheet) +// =============================== +function openOptionsSheetFromBottom() { + optionsSheetRef?.classList.add("open"); + sheetOverlay.classList.add("open"); +} + +function closeOptionsSheetFromBottom() { + optionsSheetRef?.classList.remove("open"); + sheetOverlay.classList.remove("open"); +} + +// =============================== +// CHIUSURA CLICCANDO FUORI +// =============================== +sheetOverlay.addEventListener("click", () => { + closeBottomSheet(); + closeOptionsSheetFromBottom(); + + // PATCH: spegni overlay quando chiudi il bottomsheet + sheetOverlay.classList.remove("open"); + sheetOverlay.style.pointerEvents = "none"; +}); + + +// =============================== +// CHIUSURA TOCCANDO LA MANIGLIA +// =============================== +document.querySelectorAll(".sheet-header").forEach(header => { + header.addEventListener("click", () => { + closeBottomSheet(); + closeOptionsSheetFromBottom(); + + // PATCH: spegni overlay quando chiudi il bottomsheet + sheetOverlay.classList.remove("open"); + sheetOverlay.style.pointerEvents = "none"; + }); +}); + +// =============================== +// EXPORT GLOBALI +// =============================== +window.openBottomSheet = openBottomSheet; +window.closeBottomSheet = closeBottomSheet; + +window.openOptionsSheetFromBottom = openOptionsSheetFromBottom; +window.closeOptionsSheetFromBottom = closeOptionsSheetFromBottom; + +window.BottomSheet = { + open: openBottomSheet, + close: closeBottomSheet, + openOptions: openOptionsSheetFromBottom, + closeOptions: closeOptionsSheetFromBottom +}; + +window.refreshBottomSheet = refreshBottomSheet; diff --git a/public/js/config.js b/public/js/config.js new file mode 100644 index 0000000..2a49b57 --- /dev/null +++ b/public/js/config.js @@ -0,0 +1,130 @@ +// public/js/config.js +// Configurazione globale caricata dal backend. + +(() => { + "use strict"; + + const DEFAULT_CONFIG = { + ready: false, + baseUrl: "", + pathFull: "", + galleryRefreshSeconds: 60, + workerStatusRefreshSeconds: 1, + mapboxToken: null + }; + + // Mantiene eventuali valori già inizializzati nell'HTML. + window.AppConfig = { + ...DEFAULT_CONFIG, + ...(window.AppConfig || {}) + }; + + window.BASE_URL = + window.BASE_URL ?? ""; + + window.PATH_FULL = + window.PATH_FULL ?? ""; + + window.PHOTOS_URL = + window.PHOTOS_URL ?? ""; + + window.MEDIA_BASE_ORIGIN = + window.MEDIA_BASE_ORIGIN ?? ""; + + // Mantiene invariati i percorsi restituiti dal backend. + function toAbsoluteUrl(pathOrUrl) { + return pathOrUrl + ? String(pathOrUrl) + : ""; + } + + window.toAbsoluteUrl = toAbsoluteUrl; + + async function loadConfig() { + try { + const response = await fetch("/config", { + method: "GET", + cache: "no-store", + headers: { + Accept: "application/json" + } + }); + + if (!response.ok) { + throw new Error( + `Errore configurazione: HTTP ${response.status}` + ); + } + + const data = await response.json(); + + window.AppConfig = { + ...DEFAULT_CONFIG, + ...data, + ready: true + }; + + window.BASE_URL = + window.AppConfig.baseUrl || ""; + + window.PATH_FULL = + window.AppConfig.pathFull || ""; + + console.log( + "[config] Configurazione caricata", + { + ready: + window.AppConfig.ready, + + baseUrl: + window.AppConfig.baseUrl, + + pathFullConfigured: + Boolean( + window.AppConfig.pathFull + ), + + mapboxConfigured: + Boolean( + window.AppConfig.mapboxToken + ) + } + ); + + window.dispatchEvent( + new CustomEvent("config:ready", { + detail: window.AppConfig + }) + ); + + return window.AppConfig; + } catch (error) { + console.error( + "[config] Errore caricamento:", + error + ); + + window.AppConfig = { + ...DEFAULT_CONFIG, + ...(window.AppConfig || {}), + ready: true, + error: error.message + }; + + window.dispatchEvent( + new CustomEvent("config:error", { + detail: { + error, + config: window.AppConfig + } + }) + ); + + return window.AppConfig; + } + } + + // Promessa globale usata da mapBaseLayers.js. + window.AppConfigPromise = loadConfig(); +})(); + diff --git a/public/js/faces.js b/public/js/faces.js new file mode 100644 index 0000000..4e80e80 --- /dev/null +++ b/public/js/faces.js @@ -0,0 +1,1866 @@ +(() => { + "use strict"; + + const state = { + token: "", + context: null, + targetUser: "", + clusters: [], + unclustered: [], + persons: [], + objectUrls: new Set(), + }; + + + function getToken() { + return localStorage.getItem( + "token", + ) || ""; + } + + + function getRequestedUser() { + const parameters = + new URLSearchParams( + window.location.search, + ); + + return String( + parameters.get("user") || "", + ).trim(); + } + + + function buildApiUrl(path) { + const url = new URL( + path, + window.location.origin, + ); + + const requestedUser = + getRequestedUser(); + + if (requestedUser) { + url.searchParams.set( + "user", + requestedUser, + ); + } + + return ( + url.pathname + + url.search + ); + } + + + async function apiRequest( + path, + options = {}, + ) { + const headers = { + ...(options.headers || {}), + Authorization: + `Bearer ${state.token}`, + }; + + const response = await fetch( + buildApiUrl(path), + { + ...options, + headers, + }, + ); + + if (response.status === 401) { + localStorage.removeItem("token"); + + window.location.replace( + "/login?redirect=" + + encodeURIComponent( + window.location.pathname + + window.location.search, + ), + ); + + throw new Error( + "Sessione scaduta", + ); + } + + if (!response.ok) { + let message = + `Errore HTTP ${response.status}`; + + try { + const payload = + await response.json(); + + message = + payload.error + || payload.message + || message; + } catch { + // Mantiene il messaggio HTTP. + } + + throw new Error(message); + } + + return response.json(); + } + + + async function apiWrite( + path, + body, + ) { + return apiRequest( + path, + { + method: "POST", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify(body), + }, + ); + } + + + async function loadProtectedImage( + image, + thumbnailUrl, + ) { + try { + image.classList.add("loading"); + + const response = await fetch( + buildApiUrl(thumbnailUrl), + { + headers: { + Authorization: + `Bearer ${state.token}`, + }, + }, + ); + + if (!response.ok) { + throw new Error( + `Miniatura HTTP ${response.status}`, + ); + } + + const blob = await response.blob(); + const objectUrl = + URL.createObjectURL(blob); + + state.objectUrls.add( + objectUrl, + ); + + image.src = objectUrl; + image.classList.remove("loading"); + } catch (error) { + console.error( + "[faces] errore miniatura:", + error, + ); + + const wrapper = + image.closest( + ".face-image-wrapper", + ); + + if (wrapper) { + wrapper.innerHTML = ""; + + const placeholder = + document.createElement("div"); + + placeholder.className = + "face-placeholder"; + + placeholder.textContent = + "Miniatura non disponibile"; + + wrapper.appendChild( + placeholder, + ); + } + } + } + + + function releaseObjectUrls() { + for ( + const objectUrl + of state.objectUrls + ) { + URL.revokeObjectURL( + objectUrl, + ); + } + + state.objectUrls.clear(); + } + + + function setStatus( + message, + type = "", + ) { + const panel = + document.getElementById( + "statusPanel", + ); + + panel.textContent = message; + + panel.classList.remove( + "error", + "success", + ); + + if (type) { + panel.classList.add(type); + } + } + + + function createFaceCard( + face, + options = {}, + ) { + const article = + document.createElement( + "article", + ); + + article.className = "face-card"; + + if (options.selectable) { + article.classList.add( + "selectable", + ); + + const selection = + document.createElement("label"); + + selection.className = + "face-selection"; + + const checkbox = + document.createElement("input"); + + checkbox.type = "checkbox"; + checkbox.checked = + options.checked !== false; + + checkbox.dataset.faceId = + face.id; + + const selectionText = + document.createElement("span"); + + selectionText.textContent = + "Includi"; + + selection.append( + checkbox, + selectionText, + ); + + article.appendChild(selection); + } + + const imageWrapper = + document.createElement("div"); + + imageWrapper.className = + "face-image-wrapper"; + + const image = + document.createElement("img"); + + image.className = + "face-image loading"; + + image.alt = + `Volto in ${face.photo_name}`; + + image.loading = "lazy"; + + imageWrapper.appendChild(image); + + const body = + document.createElement("div"); + + body.className = "face-body"; + + const name = + document.createElement("div"); + + name.className = "face-name"; + name.textContent = + face.photo_name + || "Fotografia"; + + const details = + document.createElement("p"); + + details.className = + "face-details"; + + const probability = + face.probability == null + ? "n/d" + : Number( + face.probability, + ).toFixed(3); + + const detectorScore = + Number( + face.detector_score || 0, + ).toFixed(3); + + details.textContent = + `Volto ${face.face_index}` + + ` · detector ${detectorScore}` + + ` · probabilità ${probability}`; + + body.append( + name, + details, + ); + + article.append( + imageWrapper, + body, + ); + + loadProtectedImage( + image, + face.thumbnail_url, + ); + + return article; + } + + + function getSelectedFaceIds( + clusterElement, + ) { + return Array.from( + clusterElement.querySelectorAll( + 'input[data-face-id\]:checked', + ), + ).map( + (checkbox) => + checkbox.dataset.faceId, + ); + } + + + async function createPerson( + name, + ) { + const response = await apiWrite( + "/face-review/persons", + { name }, + ); + + return response.person; + } + + + async function assignClusterPerson( + cluster, + clusterElement, + controls, + ) { + let personId = String( + controls.personSelect.value || "", + ).trim(); + + if (!personId) { + const name = String( + controls.nameInput.value || "", + ) + .replace(/\s+/g, " ") + .trim(); + + if (!name) { + throw new Error( + "Inserisci un nome oppure " + + "seleziona una persona" + ); + } + + const person = + await createPerson(name); + + personId = person.id; + } + + const includedFaceIds = + getSelectedFaceIds( + clusterElement, + ); + + if (!includedFaceIds.length) { + throw new Error( + "Seleziona almeno un volto" + ); + } + + controls.assignButton.disabled = true; + controls.assignButton.textContent = + "Salvataggio..."; + + try { + await apiWrite( + `/face-review/clusters/${ + encodeURIComponent(cluster.id) + }/assign-person`, + { + person_id: personId, + included_face_ids: + includedFaceIds, + }, + ); + + await loadData(); + + setStatus( + "Persona assegnata correttamente", + "success", + ); + } finally { + controls.assignButton.disabled = + false; + + controls.assignButton.textContent = + "Assegna persona"; + } + } + + + function createClusterControls( + cluster, + clusterElement, + ) { + const wrapper = + document.createElement("div"); + + wrapper.className = + "cluster-controls"; + + const nameInput = + document.createElement("input"); + + nameInput.type = "text"; + nameInput.maxLength = 120; + nameInput.placeholder = + "Nome nuova persona"; + + nameInput.className = + "person-name-input"; + + const personSelect = + document.createElement("select"); + + personSelect.className = + "person-select"; + + const emptyOption = + document.createElement("option"); + + emptyOption.value = ""; + emptyOption.textContent = + "Crea nuova persona"; + + personSelect.appendChild(emptyOption); + + for (const person of state.persons) { + const option = + document.createElement("option"); + + option.value = person.id; + option.textContent = person.name; + + if ( + cluster.person_id === person.id + ) { + option.selected = true; + } + + personSelect.appendChild(option); + } + + const assignButton = + document.createElement("button"); + + assignButton.type = "button"; + assignButton.className = + "primary-button"; + + assignButton.textContent = + "Assegna persona"; + + const controls = { + wrapper, + nameInput, + personSelect, + assignButton, + }; + + assignButton.addEventListener( + "click", + async () => { + try { + await assignClusterPerson( + cluster, + clusterElement, + controls, + ); + } catch (error) { + console.error( + "[faces] assegnazione:", + error, + ); + + setStatus( + error.message, + "error", + ); + } + }, + ); + + wrapper.append( + nameInput, + personSelect, + assignButton, + ); + + return wrapper; + } + + + function renderClusters() { + const grid = + document.getElementById( + "clustersGrid", + ); + + grid.innerHTML = ""; + + if (!state.clusters.length) { + grid.innerHTML = + '
' + + 'Nessun cluster disponibile.' + + '
'; + + return; + } + + for ( + const cluster + of state.clusters + ) { + const article = + document.createElement( + "article", + ); + + article.className = + "cluster-card"; + + const header = + document.createElement("div"); + + header.className = + "cluster-header"; + + const titleWrapper = + document.createElement("div"); + + const title = + document.createElement("h3"); + + title.className = + "cluster-title"; + + title.textContent = + cluster.person_name + || `Cluster ${cluster.label}`; + + const metadata = + document.createElement("p"); + + metadata.className = + "cluster-metadata"; + + metadata.textContent = + cluster.person_name + ? `Cluster ${cluster.label}` + : "Persona non identificata"; + + titleWrapper.append( + title, + metadata, + ); + + const badge = + document.createElement("span"); + + badge.className = + "cluster-size-badge"; + + badge.textContent = + `${cluster.size} volti`; + + header.append( + titleWrapper, + badge, + ); + + const faceGrid = + document.createElement("div"); + + faceGrid.className = + "face-grid"; + + for ( + const face + of cluster.faces || [] + ) { + faceGrid.appendChild( + createFaceCard( + face, + { + selectable: true, + checked: + face.review_status + !== "excluded", + }, + ), + ); + } + + const controls = + createClusterControls( + cluster, + article, + ); + + article.append( + header, + faceGrid, + controls, + ); + + grid.appendChild(article); + } + } + + + async function assignFacePerson( + face, + personId, + ) { + if (!personId) { + throw new Error( + "Seleziona una persona", + ); + } + + return apiWrite( + `/face-review/faces/${ + encodeURIComponent(face.id) + }/assign-person`, + { + person_id: personId, + }, + ); + } + + + function createUnclusteredFaceCard( + face, + ) { + const card = + createFaceCard(face); + + const body = + card.querySelector( + ".face-body", + ); + + const controls = + document.createElement("div"); + + controls.className = + "noise-person-controls"; + + const select = + document.createElement("select"); + + select.className = + "person-select"; + + const emptyOption = + document.createElement("option"); + + emptyOption.value = ""; + emptyOption.textContent = + "Seleziona persona"; + + select.appendChild(emptyOption); + + for (const person of state.persons) { + const option = + document.createElement( + "option", + ); + + option.value = person.id; + option.textContent = + person.name; + + select.appendChild(option); + } + + const button = + document.createElement("button"); + + button.type = "button"; + button.className = + "primary-button"; + + button.textContent = + "Assegna"; + + button.addEventListener( + "click", + async () => { + button.disabled = true; + button.textContent = + "Salvataggio..."; + + try { + await assignFacePerson( + face, + select.value, + ); + + await loadData(); + + setStatus( + "Volto assegnato " + + "alla persona", + "success", + ); + } catch (error) { + console.error( + "[faces] assegnazione " + + "volto noise:", + error, + ); + + setStatus( + error.message, + "error", + ); + + button.disabled = false; + button.textContent = + "Assegna"; + } + }, + ); + + controls.append( + select, + button, + ); + + if (body) { + body.appendChild(controls); + } + + return card; + } + + + function getSelectedNoiseFaceIds() { + return Array.from( + document.querySelectorAll( + '#unclusteredGrid ' + + 'input[data-noise-face-id\]:checked', + ), + ).map( + (checkbox) => + checkbox.dataset.noiseFaceId, + ); + } + + + function updateBulkNoiseCount( + controls, + ) { + const selected = + getSelectedNoiseFaceIds(); + + controls.assignButton.textContent = + selected.length + ? `Assegna ${selected.length} volti` + : "Assegna volti selezionati"; + + controls.assignButton.disabled = + selected.length === 0; + } + + + function setAllNoiseSelections( + checked, + controls, + ) { + document + .querySelectorAll( + '#unclusteredGrid ' + + 'input[data-noise-face-id]', + ) + .forEach((checkbox) => { + checkbox.checked = checked; + }); + + updateBulkNoiseCount(controls); + } + + + async function assignSelectedNoiseFaces( + controls, + ) { + const faceIds = + getSelectedNoiseFaceIds(); + + if (!faceIds.length) { + throw new Error( + "Seleziona almeno un volto", + ); + } + + const personId = String( + controls.personSelect.value || "", + ).trim(); + + const name = String( + controls.nameInput.value || "", + ) + .replace(/\s+/g, " ") + .trim(); + + if (!personId && !name) { + throw new Error( + "Seleziona una persona oppure " + + "inserisci un nuovo nome", + ); + } + + controls.assignButton.disabled = true; + controls.assignButton.textContent = + "Salvataggio..."; + + try { + const payload = { + face_ids: faceIds, + }; + + if (personId) { + payload.person_id = personId; + } else { + payload.name = name; + } + + const response = await apiWrite( + "/face-review/unclustered/" + + "assign-person", + payload, + ); + + await loadData(); + + setStatus( + `${response.assigned_count} volti ` + + `assegnati a ${ + response.person.name + }`, + "success", + ); + } finally { + controls.assignButton.disabled = + false; + + updateBulkNoiseCount(controls); + } + } + + + function createBulkNoiseControls() { + const wrapper = + document.createElement("div"); + + wrapper.className = + "bulk-noise-controls"; + + const selectionActions = + document.createElement("div"); + + selectionActions.className = + "bulk-selection-actions"; + + const selectAllButton = + document.createElement("button"); + + selectAllButton.type = "button"; + selectAllButton.className = + "secondary-button"; + + selectAllButton.textContent = + "Seleziona tutti"; + + const clearButton = + document.createElement("button"); + + clearButton.type = "button"; + clearButton.className = + "secondary-button"; + + clearButton.textContent = + "Deseleziona tutti"; + + selectionActions.append( + selectAllButton, + clearButton, + ); + + const nameInput = + document.createElement("input"); + + nameInput.type = "text"; + nameInput.maxLength = 120; + nameInput.placeholder = + "Nome nuova persona"; + + nameInput.className = + "person-name-input"; + + const personSelect = + document.createElement("select"); + + personSelect.className = + "person-select"; + + const emptyOption = + document.createElement("option"); + + emptyOption.value = ""; + emptyOption.textContent = + "Crea nuova persona"; + + personSelect.appendChild( + emptyOption, + ); + + for (const person of state.persons) { + const option = + document.createElement("option"); + + option.value = person.id; + option.textContent = + person.name; + + personSelect.appendChild(option); + } + + const assignButton = + document.createElement("button"); + + assignButton.type = "button"; + assignButton.className = + "primary-button"; + + assignButton.textContent = + "Assegna volti selezionati"; + + assignButton.disabled = true; + + const controls = { + wrapper, + nameInput, + personSelect, + assignButton, + }; + + selectAllButton.addEventListener( + "click", + () => { + setAllNoiseSelections( + true, + controls, + ); + }, + ); + + clearButton.addEventListener( + "click", + () => { + setAllNoiseSelections( + false, + controls, + ); + }, + ); + + assignButton.addEventListener( + "click", + async () => { + try { + await assignSelectedNoiseFaces( + controls, + ); + } catch (error) { + console.error( + "[faces] assegnazione bulk:", + error, + ); + + setStatus( + error.message, + "error", + ); + } + }, + ); + + wrapper.append( + selectionActions, + nameInput, + personSelect, + assignButton, + ); + + return controls; + } + + + function renderUnclustered() { + const grid = + document.getElementById( + "unclusteredGrid", + ); + + grid.innerHTML = ""; + + const section = + document.getElementById( + "unclusteredSection", + ); + + section + .querySelector( + ".bulk-noise-controls", + ) + ?.remove(); + + if (!state.unclustered.length) { + grid.innerHTML = + '
' + + 'Nessun volto non raggruppato.' + + '
'; + + return; + } + + const controls = + createBulkNoiseControls(); + + const heading = + section.querySelector( + ".section-heading", + ); + + heading.insertAdjacentElement( + "afterend", + controls.wrapper, + ); + + for ( + const face + of state.unclustered + ) { + const card = + createFaceCard(face); + + card.classList.add( + "noise-selectable", + ); + + const label = + document.createElement("label"); + + label.className = + "noise-selection"; + + const checkbox = + document.createElement("input"); + + checkbox.type = "checkbox"; + + checkbox.dataset.noiseFaceId = + face.id; + + const labelText = + document.createElement("span"); + + labelText.textContent = + "Seleziona"; + + checkbox.addEventListener( + "change", + () => { + updateBulkNoiseCount( + controls, + ); + }, + ); + + label.append( + checkbox, + labelText, + ); + + card.prepend(label); + + grid.appendChild(card); + } + + updateBulkNoiseCount(controls); + } + + async function apiMutation( + path, + method, + body = null, + ) { + const options = { + method, + headers: {}, + }; + + if (body !== null) { + options.headers[ + "Content-Type" + ] = "application/json"; + + options.body = + JSON.stringify(body); + } + + return apiRequest( + path, + options, + ); + } + + + function closePersonDetail() { + const overlay = + document.getElementById( + "personDetailOverlay", + ); + + if (overlay) { + overlay.remove(); + } + } + + + async function removeFaceFromPerson( + person, + face, + ) { + const confirmed = window.confirm( + `Rimuovere il volto di ${ + face.photo_name + } da ${person.name}?`, + ); + + if (!confirmed) { + return; + } + + await apiMutation( + `/face-review/persons/${ + encodeURIComponent(person.id) + }/faces/${ + encodeURIComponent(face.id) + }`, + "DELETE", + ); + } + + + async function renamePerson( + person, + input, + ) { + const name = String( + input.value || "", + ) + .replace(/\s+/g, " ") + .trim(); + + if (!name) { + throw new Error( + "Inserisci un nome valido", + ); + } + + await apiMutation( + `/face-review/persons/${ + encodeURIComponent(person.id) + }`, + "PATCH", + { name }, + ); + } + + + function createPersonFaceCard(person, face, overlay) { + const card = createFaceCard(face); + const body = card.querySelector(".face-body"); + const wrapper = card.querySelector(".face-image-wrapper"); + const badges = document.createElement("div"); + badges.className = "person-face-badges"; + + const addBadge = (text, className) => { + const badge = document.createElement("span"); + badge.className = `person-face-badge ${className}`; + badge.textContent = text; + badges.appendChild(badge); + }; + + if (face.is_prototype) { + const order = face.prototype?.selection_order; + addBadge(order == null ? "Prototype" : `Prototype #${order}`, "prototype-badge"); + } + + if (face.is_reference_excluded) { + addBadge("Caso difficile", "difficult-badge"); + } + + if ( + face.recognition?.evaluated + ) { + const recognitionScore = + Number( + face.recognition + .best_similarity, + ); + + const scoreText = + Number.isFinite( + recognitionScore, + ) + ? recognitionScore.toFixed(2) + : "n/d"; + + if ( + face.recognition.recognized + ) { + addBadge( + `Riconosciuto ${scoreText}`, + "recognized-badge", + ); + } else { + addBadge( + `Sotto soglia ${scoreText}`, + "below-threshold-badge", + ); + } + } + + if (face.cluster_label == null) { + addBadge("Noise confermato", "noise-badge"); + } else { + addBadge(`Cluster ${face.cluster_label}`, "cluster-badge"); + } + + if (wrapper) wrapper.appendChild(badges); + + const info = document.createElement("p"); + info.className = "face-details"; + info.textContent = face.cluster_label == null + ? "Senza cluster automatico" + : `Cluster ${face.cluster_label}`; + + const referenceInfo = document.createElement("div"); + referenceInfo.className = "reference-info"; + + if (face.is_prototype) { + referenceInfo.classList.add("prototype-info"); + referenceInfo.textContent = face.prototype?.selection_method === "cluster_medoid" + ? "Riferimento centrale del cluster" + : "Riferimento scelto per copertura"; + } else if (face.is_reference_excluded) { + referenceInfo.classList.add("difficult-info"); + referenceInfo.textContent = face.reference_exclusion?.note + || face.reference_exclusion?.reason_code + || "Non usare come riferimento"; + } else { + referenceInfo.hidden = true; + } + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "danger-button"; + removeButton.textContent = "Rimuovi dalla persona"; + removeButton.addEventListener("click", async () => { + removeButton.disabled = true; + try { + await removeFaceFromPerson(person, face); + overlay.remove(); + await loadData(); + setStatus("Volto rimosso dalla persona", "success"); + } catch (error) { + console.error("[faces] rimozione volto:", error); + setStatus(error.message, "error"); + removeButton.disabled = false; + } + }); + + if (body) body.append(info, referenceInfo, removeButton); + return card; + } + + + function personFaceMatches(face, filter) { + if (filter === "prototype") return Boolean(face.is_prototype); + if (filter === "clustered") return face.cluster_id != null; + if (filter === "noise") return face.cluster_id == null; + if (filter === "difficult") return Boolean(face.is_reference_excluded); + if ( + filter === "recognized" + ) { + return Boolean( + face.recognition?.evaluated + && face.recognition?.recognized, + ); + } + + if ( + filter === "below" + ) { + return Boolean( + face.recognition?.evaluated + && !face.recognition?.recognized, + ); + } + + return true; + } + + + async function openPersonDetail(personId) { + closePersonDetail(); + setStatus("Caricamento della persona..."); + + const data = await apiRequest(`/face-review/persons/${encodeURIComponent(personId)}`); + const person = data.person; + const summary = data.summary || {}; + const faces = data.faces || []; + const indexState = data.index_state || {}; + + const overlay = document.createElement("div"); + overlay.id = "personDetailOverlay"; + overlay.className = "person-detail-overlay"; + + const dialog = document.createElement("section"); + dialog.className = "person-detail-dialog"; + + const header = document.createElement("header"); + header.className = "person-detail-header"; + const heading = document.createElement("div"); + const title = document.createElement("h2"); + title.textContent = person.name; + const summaryText = document.createElement("p"); + summaryText.className = "page-subtitle"; + summaryText.textContent = `${summary.face_count || 0} volti | ${summary.cluster_count || 0} cluster | ${summary.prototype_count || 0} prototype | ${summary.difficult_face_count || 0} casi difficili`; + const coverageText = + document.createElement("p"); + + coverageText.className = + "recognition-coverage-summary"; + + if ( + summary.evaluated_face_count > 0 + ) { + const coveragePercent = + Number( + summary.recognition_coverage_percent + || 0, + ).toFixed(1); + + const recognitionThreshold = + Number( + summary.recognition_threshold + || 0.5, + ).toFixed(2); + + coverageText.textContent = + `${summary.recognized_face_count || 0}` + + ` / ${summary.evaluated_face_count}` + + " riconosciuti" + + ` | ${coveragePercent}%` + + ` | soglia ${recognitionThreshold}`; + } else { + coverageText.textContent = + "Copertura non ancora calcolata"; + } + + heading.append( + title, + summaryText, + coverageText, + ); + + const closeButton = document.createElement("button"); + closeButton.type = "button"; + closeButton.className = "secondary-button"; + closeButton.textContent = "Chiudi"; + closeButton.addEventListener("click", closePersonDetail); + header.append(heading, closeButton); + + const indexPanel = document.createElement("section"); + indexPanel.className = "recognition-index-panel"; + const indexTitle = document.createElement("strong"); + indexTitle.textContent = "Indice riconoscimento"; + const indexStatus = document.createElement("span"); + indexStatus.className = indexState.dirty ? "index-status dirty" : "index-status ready"; + indexStatus.textContent = indexState.dirty ? "Da aggiornare" : "Aggiornato"; + const indexDetails = document.createElement("span"); + indexDetails.className = "index-state-details"; + indexDetails.textContent = `Versione ${indexState.version || 0} | ${indexState.prototype_count || 0} prototype`; + indexPanel.append(indexTitle, indexStatus, indexDetails); + + const renameArea = document.createElement("div"); + renameArea.className = "person-rename-area"; + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.maxLength = 120; + nameInput.value = person.name; + nameInput.className = "person-name-input"; + const renameButton = document.createElement("button"); + renameButton.type = "button"; + renameButton.className = "primary-button"; + renameButton.textContent = "Rinomina"; + renameButton.addEventListener("click", async () => { + renameButton.disabled = true; + try { + await renamePerson(person, nameInput); + overlay.remove(); + await loadData(); + setStatus("Persona rinominata", "success"); + } catch (error) { + console.error("[faces] rinomina:", error); + setStatus(error.message, "error"); + renameButton.disabled = false; + } + }); + renameArea.append(nameInput, renameButton); + + const faceGrid = document.createElement("div"); + faceGrid.className = "face-grid person-detail-faces"; + faces.forEach((face) => faceGrid.appendChild(createPersonFaceCard(person, face, overlay))); + + const filters = document.createElement("nav"); + filters.className = "person-face-filters"; + const definitions = [ + ["all", "Tutti", summary.face_count || faces.length], + ["prototype", "Prototype", summary.prototype_count || 0], + ["clustered", "Clusterizzati", summary.clustered_face_count || 0], + ["noise", "Noise confermati", summary.confirmed_noise_count || 0], + ["recognized", "Riconosciuti", summary.recognized_face_count || 0], + ["below", "Sotto soglia", summary.below_threshold_count || 0], + ["difficult", "Casi difficili", summary.difficult_face_count || 0], + ]; + const buttons = []; + definitions.forEach(([filter, label, count]) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "person-face-filter"; + button.dataset.filter = filter; + if (filter === "all") button.classList.add("active"); + button.textContent = `${label} ${count}`; + button.addEventListener("click", () => { + buttons.forEach((item) => item.classList.toggle("active", item === button)); + Array.from(faceGrid.children).forEach((card, index) => { + card.classList.toggle("hidden", !personFaceMatches(faces[index], filter)); + }); + }); + buttons.push(button); + filters.appendChild(button); + }); + + dialog.append(header, indexPanel, renameArea, filters, faceGrid); + overlay.appendChild(dialog); + overlay.addEventListener("click", (event) => { + if (event.target === overlay) closePersonDetail(); + }); + document.body.appendChild(overlay); + setStatus(`Dettaglio di ${person.name}`, "success"); + } + + function renderPersons() { + const grid = + document.getElementById( + "personsGrid", + ); + + grid.innerHTML = ""; + + if (!state.persons.length) { + grid.innerHTML = + '
' + + 'Nessuna persona è stata ' + + 'ancora nominata.' + + '
'; + + return; + } + + for ( + const person + of state.persons + ) { + const card = + document.createElement( + "article", + ); + + card.className = + "person-card clickable"; + + card.tabIndex = 0; + card.setAttribute( + "role", + "button", + ); + + const title = + document.createElement("h3"); + + title.textContent = + person.name; + + const details = + document.createElement("p"); + + details.className = + "face-details"; + + details.textContent = + "Apri per vedere e modificare " + + "i volti assegnati"; + + const openButton = + document.createElement( + "button", + ); + + openButton.type = "button"; + openButton.className = + "secondary-button"; + + openButton.textContent = + "Apri persona"; + + const open = async () => { + try { + await openPersonDetail( + person.id, + ); + } catch (error) { + console.error( + "[faces] dettaglio persona:", + error, + ); + + setStatus( + error.message, + "error", + ); + } + }; + + openButton.addEventListener( + "click", + (event) => { + event.stopPropagation(); + open(); + }, + ); + + card.addEventListener( + "click", + open, + ); + + card.addEventListener( + "keydown", + (event) => { + if ( + event.key === "Enter" + || event.key === " " + ) { + event.preventDefault(); + open(); + } + }, + ); + + card.append( + title, + details, + openButton, + ); + + grid.appendChild(card); + } + } + + function updateCounters() { + document.getElementById( + "clusterCount", + ).textContent = + String(state.clusters.length); + + document.getElementById( + "unclusteredCount", + ).textContent = + String( + state.unclustered.length, + ); + + document.getElementById( + "personCount", + ).textContent = + String(state.persons.length); + } + + + function initializeTabs() { + const tabs = + document.querySelectorAll( + ".review-tab", + ); + + const sections = + document.querySelectorAll( + "[data-section]", + ); + + for (const tab of tabs) { + tab.addEventListener( + "click", + () => { + const selected = + tab.dataset.tab; + + for ( + const currentTab + of tabs + ) { + currentTab.classList.toggle( + "active", + currentTab === tab, + ); + } + + for ( + const section + of sections + ) { + section.classList.toggle( + "hidden", + section.dataset.section + !== selected, + ); + } + }, + ); + } + } + + + function initializeButtons() { + document + .getElementById("refreshButton") + .addEventListener( + "click", + loadData, + ); + + document + .getElementById("backButton") + .addEventListener( + "click", + () => { + if ( + state.context?.is_admin + ) { + window.location.href = + "/admin.html"; + } else { + window.location.href = "/"; + } + }, + ); + } + + + async function loadData() { + try { + releaseObjectUrls(); + + setStatus( + "Caricamento dei dati...", + ); + + const context = + await apiRequest( + "/face-review/context", + ); + + state.context = context; + state.targetUser = + context.target_user; + + const [ + clusterData, + unclusteredData, + personData, + ] = await Promise.all([ + apiRequest( + "/face-review/clusters", + ), + apiRequest( + "/face-review/unclustered", + ), + apiRequest( + "/face-review/persons", + ), + ]); + + state.clusters = + clusterData.clusters || []; + + state.unclustered = + unclusteredData.faces || []; + + state.persons = + personData.persons || []; + + document.getElementById( + "pageSubtitle", + ).textContent = + `Libreria di ${state.targetUser}`; + + renderClusters(); + renderUnclustered(); + renderPersons(); + updateCounters(); + + setStatus( + `${state.clusters.length} cluster, ` + + `${state.unclustered.length} ` + + `volti non raggruppati`, + "success", + ); + } catch (error) { + console.error( + "[faces] caricamento fallito:", + error, + ); + + setStatus( + error.message, + "error", + ); + } + } + + + function initialize() { + state.token = getToken(); + + if ( + !window.AppAuth + || !window.AppAuth.requireAuth() + ) { + return; + } + + const loader = + document.getElementById( + "authLoader", + ); + + const app = + document.getElementById("app"); + + if (loader) { + loader.hidden = true; + loader.style.display = "none"; + } + + if (!app) { + throw new Error( + "Contenitore applicazione non trovato", + ); + } + + app.hidden = false; + app.removeAttribute("aria-hidden"); + app.style.removeProperty("display"); + + initializeTabs(); + initializeButtons(); + loadData(); + } + + + window.addEventListener( + "beforeunload", + releaseObjectUrls, + ); + + if ( + document.readyState + === "loading" + ) { + document.addEventListener( + "DOMContentLoaded", + initialize, + { + once: true, + }, + ); + } else { + initialize(); + } +})(); diff --git a/public/js/gallery.js b/public/js/gallery.js new file mode 100644 index 0000000..c855b3b --- /dev/null +++ b/public/js/gallery.js @@ -0,0 +1,499 @@ +// public/js/gallery.js +// Coordinatore della galleria: caricamento progressivo delle foto. + +(() => { + "use strict"; + + const ACTIVITY_EXTENSIONS = ["fit", "tcx", "gpx"]; + const PAGE_SIZE = 40; + + let photoItems = []; + let nextCursor = null; + let hasMorePhotos = true; + let loadingPhotos = false; + let photoRequestId = 0; + + // Stato separato per la paginazione della galleria Attività. + let activityItems = []; + let nextActivityCursor = null; + let hasMoreActivities = true; + let loadingActivities = false; + let activityRequestId = 0; + + function isAuthenticated() { + return ( + document.body.classList.contains("authenticated") && + window.AppAuth && + typeof window.AppAuth.isLoggedIn === "function" && + window.AppAuth.isLoggedIn() + ); + } + + function filterByMode(items) { + if (window.state?.mode === "photos") { + return items.filter(item => + item.mime_type?.startsWith("image/") || + item.mime_type?.startsWith("video/") + ); + } + + return items.filter(item => { + const format = String( + item.format || item.file_extension || item.mime_type || "" + ).toLowerCase(); + + const name = String( + item.name || item.file_name || item.path || "" + ).toLowerCase(); + + return ACTIVITY_EXTENSIONS.some(extension => + format === extension || + format.includes(extension) || + name.endsWith(`.${extension}`) + ); + }); + } + + function getPhotoQuery(cursor) { + const params = new URLSearchParams(); + params.set("limit", String(PAGE_SIZE)); + params.set("sort", window.currentSort || "desc"); + + const showDeleted = localStorage.getItem("showDeleted") === "1"; + if (showDeleted) params.set("showDeleted", "1"); + + const search = String(window.currentSearch || "").trim(); + const year = String(window.currentYear || "").trim(); + const month = String(window.currentMonth || "").trim(); + const folder = String(window.currentFolder || "").trim(); + const filter = String(window.currentFilter || ""); + + if (search) params.set("search", search); + if (year) params.set("year", year); + if (month) params.set("month", month); + if (folder) params.set("folder", folder); + if (filter && filter !== "all") params.set("filter", filter); + if (cursor) params.set("cursor", cursor); + + return params; + } + + function renderPhotoItems(append = false) { + const filtered = filterByMode(photoItems); + const sections = window.GalleryUtils.groupByDate( + filtered, + window.currentGroup || "auto" + ); + + window.GalleryRenderer.renderGallery(sections, append); + } + + async function loadNextPhotoPage(requestId, firstPage = false) { + if ( + loadingPhotos || + !hasMorePhotos || + requestId !== photoRequestId || + !isAuthenticated() + ) { + return; + } + + loadingPhotos = true; + + try { + const token = localStorage.getItem("token"); + const params = getPhotoQuery(firstPage ? null : nextCursor); + + const response = await fetch(`/photos/gallery?${params.toString()}`, { + headers: { Authorization: "Bearer " + token } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + + if (requestId !== photoRequestId) return; + + if (!Array.isArray(data.items)) { + throw new Error("Risposta gallery senza array items"); + } + + const existingIds = new Set( + photoItems.map(item => String(item.id)) + ); + const newlyAddedItems = []; + + for (const item of data.items) { + const id = String(item.id); + if (!existingIds.has(id)) { + photoItems.push(item); + newlyAddedItems.push(item); + existingIds.add(id); + } + } + + // Aggiorna l'indice della mappa soltanto con le nuove foto. + if (newlyAddedItems.length && window.state?.mode === "photos") { + window.MapSpatialIndex?.append?.(newlyAddedItems); + + if ( + window.MapEngine?.isReady?.() && + typeof window.renderPhotoMapLayer === "function" + ) { + window.renderPhotoMapLayer(); + } + } + + nextCursor = data.nextCursor || null; + hasMorePhotos = Boolean(data.hasMore && nextCursor); + + renderPhotoItems(!firstPage); + + console.log( + `[gallery] paginazione: caricati ${data.items.length}, ` + + `totale in sessione ${photoItems.length}, altri=${hasMorePhotos}` + ); + } catch (error) { + console.error("[gallery] errore caricamento pagina:", error); + } finally { + if (requestId === photoRequestId) { + loadingPhotos = false; + } + + // Se la prima pagina non riempie lo schermo, continua a caricare + // finché lo scorrimento diventa possibile. + if ( + requestId === photoRequestId && + hasMorePhotos && + !loadingPhotos && + window.innerHeight + window.scrollY >= + document.documentElement.scrollHeight - 500 + ) { + loadNextPhotoPage(requestId); + } + } + } + + function filterActivityItems(items) { + const query = String(window.currentSearch || "") + .trim() + .toLocaleLowerCase("it-IT"); + const year = String(window.currentYear || "").trim(); + const month = String(window.currentMonth || "").trim(); + const folder = String(window.currentFolder || "").trim(); + + return items.filter(item => { + if (folder && String(item.cartella || "") !== folder) return false; + + if (query) { + const text = [ + item.name, item.file_name, item.path, item.cartella, + item.display_name, item.route_name, item.title, + item.description, item.sport, item.activity_type + ].filter(value => value != null).join(" ") + .toLocaleLowerCase("it-IT"); + + if (!text.includes(query)) return false; + } + + if (year || month) { + if (!item.taken_at) return false; + const date = new Date(item.taken_at); + if (Number.isNaN(date.getTime())) return false; + if (year && date.getFullYear() !== Number(year)) return false; + if (month && date.getMonth() + 1 !== Number(month)) return false; + } + + return true; + }); + } + + function renderActivityItems(append = false) { + const showDeleted = localStorage.getItem("showDeleted") === "1"; + const visible = showDeleted + ? activityItems + : activityItems.filter(item => !item.deleted_at); + + const filtered = filterActivityItems(visible); + const sorted = window.GalleryUtils.sortByDate( + filtered, + window.currentSort || "desc" + ); + const sections = window.GalleryUtils.groupByDate( + sorted, + window.currentGroup || "auto" + ); + + window.GalleryRenderer.renderGallery(sections, append); + } + + async function loadNextActivityPage(requestId, firstPage = false) { + if ( + loadingActivities || + !hasMoreActivities || + requestId !== activityRequestId || + !isAuthenticated() || + window.state?.mode === "photos" + ) return; + + loadingActivities = true; + + try { + const token = localStorage.getItem("token"); + const params = new URLSearchParams(); + params.set("limit", String(PAGE_SIZE)); + params.set("sort", window.currentSort || "desc"); + + if (localStorage.getItem("showDeleted") === "1") { + params.set("showDeleted", "1"); + } + if (!firstPage && nextActivityCursor) { + params.set("cursor", nextActivityCursor); + } + + const response = await fetch( + `/activities/gallery?${params.toString()}`, + { headers: { Authorization: "Bearer " + token } } + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + if (requestId !== activityRequestId) return; + if (!Array.isArray(data.items)) { + throw new Error("Risposta attività senza array items"); + } + + const existingIds = new Set( + activityItems.map(item => String(item.id)) + ); + + for (const item of data.items) { + const id = String(item.id); + if (!existingIds.has(id)) { + activityItems.push(item); + existingIds.add(id); + } + } + + nextActivityCursor = data.nextCursor || null; + hasMoreActivities = Boolean(data.hasMore && nextActivityCursor); + + renderActivityItems(!firstPage); + + console.log( + `[gallery attività] pagina=${data.items.length}, ` + + `totale=${activityItems.length}, altri=${hasMoreActivities}` + ); + } catch (error) { + console.error("[gallery attività] errore caricamento pagina:", error); + } finally { + if (requestId === activityRequestId) loadingActivities = false; + + // Continua se la pagina non basta ancora a riempire lo schermo. + if ( + requestId === activityRequestId && + hasMoreActivities && + !loadingActivities && + window.state?.mode !== "photos" && + window.innerHeight + window.scrollY >= + document.documentElement.scrollHeight - 500 + ) { + loadNextActivityPage(requestId); + } + } + } + + function refreshGallery() { + if (!isAuthenticated()) { + console.warn("[refreshGallery] autenticazione non completata"); + return; + } + + if (window.state?.mode !== "photos") { + activityRequestId++; + const requestId = activityRequestId; + + activityItems = []; + nextActivityCursor = null; + hasMoreActivities = true; + loadingActivities = false; + + const gallery = document.getElementById("gallery"); + if (gallery) { + gallery.replaceChildren(); + const message = document.createElement("p"); + message.className = "gallery-loading"; + message.textContent = "Caricamento attività…"; + gallery.appendChild(message); + } + + loadNextActivityPage(requestId, true); + return; + } + + photoRequestId++; + const requestId = photoRequestId; + + photoItems = []; + nextCursor = null; + hasMorePhotos = true; + loadingPhotos = false; + + + const gallery = document.getElementById("gallery"); + if (gallery) { + gallery.replaceChildren(); + const message = document.createElement("p"); + message.className = "gallery-loading"; + message.textContent = "Caricamento foto…"; + gallery.appendChild(message); + } + + loadNextPhotoPage(requestId, true); + } + + function initializeGallery() { + if (!isAuthenticated()) { + console.warn("[gallery] inizializzazione bloccata: autenticazione"); + return; + } + + document.addEventListener("showDeletedChange", (event) => { + if (!isAuthenticated()) return; + + const enabled = Boolean(event.detail?.enabled); + localStorage.setItem("showDeleted", enabled ? "1" : "0"); + window.refreshMapPhotoIndex?.(); + refreshGallery(); + }); + + // Non caricare tutta la cache locale quando siamo nella modalità foto. + // La modalità attività conserva il caricamento locale precedente. + if (window.state?.mode !== "photos") { + window.loadLocalState?.(); + } + + window.addEventListener("scroll", () => { + const nearBottom = window.innerHeight + window.scrollY >= + document.documentElement.scrollHeight - 700; + + if (!nearBottom) return; + + if (window.state?.mode === "photos") { + if (hasMorePhotos && !loadingPhotos) { + loadNextPhotoPage(photoRequestId); + } + } else if (hasMoreActivities && !loadingActivities) { + loadNextActivityPage(activityRequestId); + } + }, { passive: true }); + + refreshGallery(); + } + + + // Aggiorna gli elementi sincronizzati senza azzerare la paginazione. + window.applySyncedGalleryItems = function (mode, items, removedId = null) { + const syncMode = mode === "activities" ? "activities" : "photos"; + + if (window.state?.mode !== syncMode) return false; + + const target = syncMode === "photos" ? photoItems : activityItems; + + if (removedId != null) { + const id = String(removedId); + const index = target.findIndex( + existing => String(existing.id) === id + ); + + if (index >= 0) target.splice(index, 1); + + if (syncMode === "photos") { + window.MapSpatialIndex?.remove?.(id); + if (window.MapEngine?.isReady?.()) { + window.renderPhotoMapLayer?.(); + } + } + + const savedScrollY = window.scrollY; + if (syncMode === "photos") renderPhotoItems(false); + else renderActivityItems(false); + + window.requestAnimationFrame(() => window.scrollTo(0, savedScrollY)); + return true; + } + + if (!Array.isArray(items) || items.length === 0) return false; + + const addedPhotos = []; + + for (const item of items) { + if (!item || item.id == null) continue; + + const id = String(item.id); + const index = target.findIndex( + existing => String(existing.id) === id + ); + + if (index >= 0) { + target[index] = item; + } else { + target.push(item); + if (syncMode === "photos") addedPhotos.push(item); + } + } + + if (syncMode === "photos") { + photoItems = window.GalleryUtils.sortByDate( + photoItems, + window.currentSort || "desc" + ); + + if (addedPhotos.length) { + window.MapSpatialIndex?.append?.(addedPhotos); + + if (window.MapEngine?.isReady?.() && + typeof window.renderPhotoMapLayer === "function") { + window.renderPhotoMapLayer(); + } + } + } + + const savedScrollY = window.scrollY; + + if (syncMode === "photos") { + renderPhotoItems(false); + } else { + renderActivityItems(false); + } + + window.requestAnimationFrame(() => { + window.scrollTo(0, savedScrollY); + }); + + return true; + }; + + window.refreshGallery = refreshGallery; + + // Espone un caricamento aggiuntivo per futuri collegamenti alla navigazione + // del dettaglio, senza cambiare per ora modal.js. + window.loadNextGalleryPhotoPage = () => + loadNextPhotoPage(photoRequestId); + + window.loadNextGalleryActivityPage = () => + loadNextActivityPage(activityRequestId); + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializeGallery, { + once: true + }); + } else { + initializeGallery(); + } +})(); diff --git a/public/js/galleryRenderer.js b/public/js/galleryRenderer.js new file mode 100644 index 0000000..5100aec --- /dev/null +++ b/public/js/galleryRenderer.js @@ -0,0 +1,419 @@ +// public/js/galleryRenderer.js +// Virtualizzazione della timeline: foto e attività. + +(() => { + "use strict"; + + const OVERSCAN_ROWS = 4; + + let currentSections = []; + let framePending = false; + + function createPhotoTile(item, sectionItems, itemIndex, globalIndex) { + const tile = document.createElement("div"); + tile.className = "thumb"; + tile.id = `photo_${item.id}`; + + if (item.deleted_at) tile.classList.add("soft-deleted"); + + const gridThumb = window.toAbsoluteUrl( + item.thub1 || item.thub2 || item.path, + item.name, + "thumbs", + item.cartella + ); + + const modalPreview = window.toAbsoluteUrl( + item.thub2 || item.thub1 || item.path, + item.name, + "thumbs", + item.cartella + ); + + const original = window.toAbsoluteUrl( + item.path, + item.name, + "original", + item.cartella + ); + + const image = document.createElement("img"); + image.src = gridThumb; + image.alt = item.name || ""; + image.loading = globalIndex < 20 ? "eager" : "lazy"; + image.decoding = "async"; + + if (globalIndex < 8) image.fetchPriority = "high"; + + tile.appendChild(image); + + // Nome completo visibile solo quando sono abilitate le soft delete. + if (localStorage.getItem("showDeleted") === "1" && item.name) { + const filename = document.createElement("div"); + filename.className = "thumb-filename"; + filename.textContent = item.name; + filename.title = item.name; + tile.appendChild(filename); + } + + if (item.mime_type?.startsWith("video/")) { + const play = document.createElement("div"); + play.className = "play-icon"; + play.textContent = "▶"; + tile.appendChild(play); + } + + window.GallerySelection?.bindTile( + tile, "photos", item, () => { + window.closeBottomSheet?.(); + + if (typeof window.openModalFromList === "function") { + window.openModalFromList(sectionItems, itemIndex); + } else { + window.openModal?.(original, modalPreview, item); + } + } + ); + + return tile; + } + + function createActivityTile(item, sectionItems, itemIndex) { + const tile = document.createElement("div"); + tile.className = "activity-tile"; + tile.id = `activity_${String(item.id || "").replaceAll("/", "_")}`; + + if (item.deleted_at) tile.classList.add("soft-deleted"); + + const routeBox = document.createElement("div"); + routeBox.className = "activity-route-thumb loading"; + routeBox.innerHTML = + '
Caricamento percorso...
'; + + tile.appendChild(routeBox); + window.ActivityThumbs?.renderRouteThumb(routeBox, item); + + const label = document.createElement("div"); + label.className = "activity-label"; + label.textContent = + item.display_name || + item.route_name || + item.title || + item.name || + "(senza nome)"; + + tile.appendChild(label); + + // Nome file completo, estensione compresa, quando le soft delete sono visibili. + if (localStorage.getItem("showDeleted") === "1" && item.name) { + const filename = document.createElement("div"); + filename.className = "thumb-filename"; + filename.textContent = item.name; + filename.title = item.name; + tile.appendChild(filename); + } + + const metaText = window.GalleryUtils?.formatActivityMeta(item) || ""; + + if (metaText) { + const meta = document.createElement("div"); + meta.className = "activity-meta"; + meta.textContent = metaText; + tile.appendChild(meta); + } + + window.GallerySelection?.bindTile( + tile, "activities", item, async () => { + window.closeBottomSheet?.(); + + if (typeof window.openActivityModal !== "function") { + console.error( + "[galleryRenderer] openActivityModal non disponibile" + ); + return; + } + + await window.openActivityModal(item, sectionItems, itemIndex); + } + ); + + return tile; + } + + function getLayout(container) { + const activities = window.state?.mode === "activities"; + const width = Math.max(1, container.clientWidth - 8); + + const baseMinTile = activities + ? (window.innerWidth <= 420 + ? 110 + : window.innerWidth >= 700 + ? 140 + : 120) + : (window.innerWidth <= 420 ? 96 : 100); + + const minTile = window.GalleryZoom + ? window.GalleryZoom.getMinTile(activities, baseMinTile) + : baseMinTile; + + const gap = activities ? 6 : 4; + const columns = Math.max( + 1, + Math.floor((width + gap) / (minTile + gap)) + ); + + const tileWidth = (width - gap * (columns - 1)) / columns; + + return { + activities, + columns, + gap, + rowHeight: tileWidth + gap, + width + }; + } + + function updateSection(entry, viewportHeight) { + const { container, section, windowGrid } = entry; + const count = section.items.length; + const layout = getLayout(container); + + const totalRows = Math.ceil(count / layout.columns); + const totalHeight = totalRows + ? totalRows * layout.rowHeight - layout.gap + : 0; + + container.style.height = `${Math.max(0, totalHeight)}px`; + + const rect = container.getBoundingClientRect(); + + let firstRow = Math.floor( + -rect.top / layout.rowHeight - OVERSCAN_ROWS + ); + + let lastRow = Math.ceil( + (viewportHeight - rect.top) / layout.rowHeight + OVERSCAN_ROWS + ); + + firstRow = Math.max(0, Math.min(totalRows, firstRow)); + lastRow = Math.max(firstRow, Math.min(totalRows, lastRow)); + + const signature = [ + layout.activities ? "activities" : "photos", + layout.columns, + layout.rowHeight.toFixed(2), + layout.width.toFixed(2), + firstRow, + lastRow + ].join(":"); + + if (entry.signature === signature) return; + entry.signature = signature; + + const firstIndex = firstRow * layout.columns; + const lastIndex = Math.min(count, lastRow * layout.columns); + + windowGrid.style.top = `${firstRow * layout.rowHeight}px`; + windowGrid.style.gridTemplateColumns = + `repeat(${layout.columns}, minmax(0, 1fr))`; + windowGrid.style.gap = `${layout.gap}px`; + + const wanted = new Set(); + + for (let i = firstIndex; i < lastIndex; i++) { + wanted.add(i); + } + + // Elimina dalla cache locale solo gli elementi fuori dalla finestra. + for (const [index, node] of entry.rendered) { + if (!wanted.has(index)) { + node.remove(); + entry.rendered.delete(index); + } + } + + const fragment = document.createDocumentFragment(); + + for (let i = firstIndex; i < lastIndex; i++) { + let tile = entry.rendered.get(i); + + if (!tile) { + const item = section.items[i]; + + tile = layout.activities + ? createActivityTile(item, section.items, i) + : createPhotoTile( + item, + section.items, + i, + section.globalStart + i + ); + + entry.rendered.set(i, tile); + } + + fragment.appendChild(tile); + } + + windowGrid.replaceChildren(fragment); + } + + function updateVisibleSections() { + framePending = false; + + const viewportHeight = window.innerHeight; + + for (const entry of currentSections) { + updateSection(entry, viewportHeight); + } + } + + function refreshLayout() { + // Invalida le geometrie memorizzate: la galleria potrebbe + // essere stata nascosta mentre la mappa era aperta. + for (const entry of currentSections) { + entry.signature = null; + } + + // Attende che il browser abbia ripristinato il layout visibile. + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + updateVisibleSections(); + }); + }); + } + + function scheduleUpdate() { + if (framePending) return; + + framePending = true; + window.requestAnimationFrame(updateVisibleSections); + } + + function renderGallery(sections, append = false) { + const gallery = document.getElementById("gallery"); + if (!gallery) return; + + const incoming = Array.isArray(sections) ? sections : []; + const activities = window.state?.mode === "activities"; + + function createEntry(section, globalStart, fragment) { + const items = section.items || []; + + const heading = document.createElement("h2"); + heading.className = "gallery-section-title"; + heading.textContent = section.label; + fragment.appendChild(heading); + + const container = document.createElement("div"); + container.className = "gallery-section gallery-virtualized"; + + const windowGrid = document.createElement("div"); + windowGrid.className = "gallery-virtual-window"; + + container.appendChild(windowGrid); + fragment.appendChild(container); + + return { + section: { + label: section.label, + items, + globalStart + }, + heading, + container, + windowGrid, + rendered: new Map(), + signature: null + }; + } + + // Riutilizza il DOM esistente quando arriva una pagina successiva, + // sia per le foto sia per le attività, se le sezioni restano in ordine. + if (append && currentSections.length > 0) { + const isPrefix = + currentSections.length <= incoming.length && + currentSections.every( + (entry, index) => + entry.section.label === incoming[index].label + ); + + if (isPrefix) { + const fragment = document.createDocumentFragment(); + let globalPhotoIndex = 0; + + for (let i = 0; i < incoming.length; i++) { + const section = incoming[i]; + const newItems = section.items || []; + let entry = currentSections[i]; + + if (entry) { + // Mantiene lo stesso array: le miniature già esistenti + // conservano i riferimenti usati per aprire il visualizzatore. + const oldItems = entry.section.items; + const oldLength = oldItems.length; + const common = Math.min(oldLength, newItems.length); + + // Aggiorna gli elementi esistenti e copia davvero quelli nuovi. + for (let j = 0; j < common; j++) { + oldItems[j] = newItems[j]; + } + + for (let j = oldLength; j < newItems.length; j++) { + oldItems.push(newItems[j]); + } + + if (newItems.length < oldLength) { + oldItems.length = newItems.length; + } + + // Forza l'aggiornamento della finestra virtualizzata + // quando cambia il numero di elementi della sezione. + entry.signature = null; + entry.section.label = section.label; + entry.heading.textContent = section.label; + entry.section.globalStart = globalPhotoIndex; + + globalPhotoIndex += oldItems.length; + } else { + entry = createEntry(section, globalPhotoIndex, fragment); + currentSections.push(entry); + globalPhotoIndex += entry.section.items.length; + } + } + + // Aggiunge solo le nuove sezioni, senza sostituire quelle vecchie. + if (fragment.childNodes.length) { + gallery.appendChild(fragment); + } + + updateVisibleSections(); + return; + } + } + + // Rendering completo per prima pagina, filtri e modalità attività. + const fragment = document.createDocumentFragment(); + const entries = []; + let globalPhotoIndex = 0; + + for (const section of incoming) { + const entry = createEntry(section, globalPhotoIndex, fragment); + entries.push(entry); + + if (!activities) { + globalPhotoIndex += entry.section.items.length; + } + } + + currentSections = entries; + gallery.replaceChildren(fragment); + updateVisibleSections(); + } + + window.addEventListener("scroll", scheduleUpdate, { passive: true }); + window.addEventListener("resize", scheduleUpdate, { passive: true }); + + window.GalleryRenderer = Object.freeze({ renderGallery, refreshLayout }); + window.renderGallery = renderGallery; +})(); diff --git a/public/js/gallerySelection.js b/public/js/gallerySelection.js new file mode 100644 index 0000000..c0341da --- /dev/null +++ b/public/js/gallerySelection.js @@ -0,0 +1,490 @@ +// Selezione multipla persistente rispetto alla virtualizzazione. +(() => { + "use strict"; + + let mode = null; + const selected = new Map(); + let longPressTimer = null; + let longPressPointer = null; + let suppressClickUntil = 0; + + const keyOf = (item) => String(item?.id ?? ""); + + function isSelected(type, item) { + return mode === type && selected.has(keyOf(item)); + } + + function updateTile(tile, type, item) { + const active = isSelected(type, item); + tile.classList.toggle("selection-selected", active); + tile.setAttribute("aria-selected", String(active)); + } + + function updateAllTiles() { + document.querySelectorAll("[data-selection-id]").forEach((tile) => { + const type = tile.dataset.selectionType; + const active = + mode === type && selected.has(tile.dataset.selectionId); + + tile.classList.toggle("selection-selected", active); + tile.setAttribute("aria-selected", String(active)); + }); + } + + function updateUI() { + const active = mode !== null && selected.size > 0; + document.body.classList.toggle("selection-mode", active); + document.body.classList.toggle( + "selection-photos", + active && mode === "photos" + ); + document.body.classList.toggle( + "selection-activities", + active && mode === "activities" + ); + + const bar = document.getElementById("selectionBar"); + const count = document.getElementById("selectionCount"); + const actions = document.getElementById("selectionActionsMenu"); + + if (bar) bar.hidden = !active; + if (count) { + count.textContent = active + ? `${selected.size} selezionati` + : ""; + } + if (actions) actions.hidden = true; + + updateAllTiles(); + } + + function begin(type, item) { + if (!item || !keyOf(item)) return; + + if (mode !== type) { + selected.clear(); + mode = type; + } + + selected.set(keyOf(item), item); + updateUI(); + } + + function toggle(type, item) { + if (!item || !keyOf(item)) return; + + if (mode !== type) { + begin(type, item); + return; + } + + const key = keyOf(item); + if (selected.has(key)) { + selected.delete(key); + } else { + selected.set(key, item); + } + + if (selected.size === 0) mode = null; + updateUI(); + } + + function clear() { + selected.clear(); + mode = null; + updateUI(); + } + + + + let downloadRunning = false; + + async function downloadSelectedPhotos() { + if (downloadRunning) return; + + const notify = (message, options = {}) => { + if (window.operationBanner) { + window.operationBanner.show(message, options); + } else { + alert(message); + } + }; + + if (mode !== "photos") { + notify("Il download è disponibile solo per le foto.", { type: "error" }); + return; + } + + const items = [...selected.values()].filter( + item => item && item.id != null && String(item.id).trim() !== "" + ); + const ids = [...new Set(items.map(item => String(item.id)))]; + + if (!ids.length) { + notify("Nessuna foto selezionata.", { type: "error" }); + return; + } + + const token = localStorage.getItem("token"); + if (!token) { + notify("Sessione non valida. Accedi nuovamente.", { type: "error" }); + return; + } + + const button = document.getElementById("selectionDownloadBtn"); + const originalButtonHTML = button?.innerHTML; + downloadRunning = true; + + if (button) { + button.disabled = true; + button.textContent = "Preparazione..."; + } + + notify(`Preparazione del download di ${ids.length} foto...`, { + type: "download", + busy: true, + sticky: true + }); + + try { + const response = await fetch("/photos/download", { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ ids }) + }); + + if (!response.ok) { + let message = "Download non riuscito."; + try { + const data = await response.json(); + if (data?.error) message = data.error; + } catch {} + throw new Error(message); + } + + notify("Download in corso: ricezione del file...", { + type: "download", + busy: true, + sticky: true + }); + + const blob = await response.blob(); + const disposition = response.headers.get("Content-Disposition") || ""; + let filename = ids.length === 1 + ? String(items.find(item => String(item.id) === ids[0])?.name || "foto") + : "foto-selezionate.zip"; + + const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); + const plainMatch = disposition.match(/filename="?([^";]+)"?/i); + + if (utf8Match) { + try { filename = decodeURIComponent(utf8Match[1]); } catch {} + } else if (plainMatch) { + filename = plainMatch[1]; + } + + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60000); + + notify(`Download avviato: ${filename}`, { + type: "success", + duration: 5000 + }); + } catch (err) { + notify(err?.message || "Errore durante il download.", { + type: "error", + duration: 7000 + }); + } finally { + downloadRunning = false; + if (button) { + button.disabled = false; + if (originalButtonHTML !== undefined) { + button.innerHTML = originalButtonHTML; + } + } + } + } + + let softToggleRunning = false; + + async function toggleSelectedPhotos() { + if (softToggleRunning) return; + + if (mode !== "photos") { + alert("Questa azione è disponibile solo per le foto."); + return; + } + + const items = [...selected.values()].filter( + item => item && item.id != null && String(item.id) !== "" + ); + + if (!items.length) { + alert("Nessuna foto selezionata."); + return; + } + + if (!confirm( + `Invertire lo stato cestino di ${items.length} foto selezionate?\n\n` + + "Le foto nel cestino saranno ripristinate; le altre saranno cestinate." + )) return; + + const token = localStorage.getItem("token"); + if (!token) { + alert("Sessione non valida. Accedi nuovamente."); + return; + } + + softToggleRunning = true; + const button = document.getElementById("selectionToggleSoftBtn"); + if (button) { + button.disabled = true; + button.textContent = "Elaborazione…"; + } + + let succeeded = 0; + const failed = []; + + try { + // Sequenziale: ogni foto viene inviata una sola volta. + // Non ripetere automaticamente le richieste: l'endpoint è un toggle. + for (const item of items) { + try { + const response = await fetch( + `/photos/${encodeURIComponent(String(item.id))}/toggle-soft`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}` + } + } + ); + + let result = null; + try { + result = await response.json(); + } catch { + // La risposta può non contenere JSON. + } + + if (!response.ok || result?.ok !== true) { + failed.push(String(item.id)); + console.error( + "[GallerySelection] Toggle soft fallito:", + item.id, + response.status, + result + ); + continue; + } + + succeeded++; + } catch (error) { + failed.push(String(item.id)); + console.error( + "[GallerySelection] Errore toggle soft:", + item.id, + error + ); + } + } + + if (succeeded > 0) { + clear(); + window.refreshGallery?.(); + } + + if (failed.length) { + alert( + `Operazione terminata.\n` + + `Riuscite: ${succeeded}\n` + + `Fallite: ${failed.length}\n\n` + + `ID non riusciti: ${failed.join(", ")}\n\n` + + (succeeded > 0 + ? "La galleria è stata aggiornata. Verifica gli ID non riusciti prima di riprovare." + : "Nessuna modifica confermata; la selezione è rimasta attiva.") + ); + } else { + alert(`Operazione completata: ${succeeded} foto aggiornate.`); + } + } finally { + softToggleRunning = false; + if (button) { + button.disabled = false; + button.textContent = "Soft delete"; + } + } + } + + function bindTile(tile, type, item, openItem) { + const key = keyOf(item); + tile.dataset.selectionId = key; + tile.dataset.selectionType = type; + tile.setAttribute("aria-selected", "false"); + + updateTile(tile, type, item); + + tile.addEventListener("pointerdown", (event) => { + if (mode !== null) return; + if (event.pointerType === "mouse" && event.button !== 0) return; + + clearTimeout(longPressTimer); + longPressPointer = { + id: event.pointerId, + x: event.clientX, + y: event.clientY + }; + + longPressTimer = setTimeout(() => { + begin(type, item); + suppressClickUntil = Date.now() + 1000; + longPressPointer = null; + }, 550); + }); + + tile.addEventListener("pointermove", (event) => { + if (!longPressPointer || + longPressPointer.id !== event.pointerId) return; + + const dx = event.clientX - longPressPointer.x; + const dy = event.clientY - longPressPointer.y; + + if (Math.hypot(dx, dy) > 12) { + clearTimeout(longPressTimer); + longPressPointer = null; + } + }); + + const stopPress = () => { + clearTimeout(longPressTimer); + longPressPointer = null; + }; + + tile.addEventListener("pointerup", stopPress); + tile.addEventListener("pointercancel", stopPress); + tile.addEventListener("contextmenu", (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + + tile.addEventListener("click", async (event) => { + if (Date.now() < suppressClickUntil) { + event.preventDefault(); + event.stopPropagation(); + suppressClickUntil = 0; + return; + } + + if (mode !== null) { + event.preventDefault(); + event.stopPropagation(); + toggle(type, item); + return; + } + + await openItem?.(); + }); + } + + document.addEventListener("DOMContentLoaded", () => { + document.getElementById("selectionCancel")?.addEventListener( + "click", clear + ); + + const actionsButton = document.getElementById("selectionActionsBtn"); + const actionsMenu = document.getElementById("selectionActionsMenu"); + + document.getElementById("selectionDownloadBtn")?.addEventListener( + "click", + async () => { + if (actionsMenu) actionsMenu.hidden = true; + await downloadSelectedPhotos(); + } + ); + + document.getElementById("selectionToggleSoftBtn")?.addEventListener( + "click", + async () => { + if (actionsMenu) actionsMenu.hidden = true; + await toggleSelectedPhotos(); + } + ); + + document.getElementById("selectionOtherBtn")?.addEventListener( + "click", + () => { + if (actionsMenu) actionsMenu.hidden = true; + alert("Altre azioni saranno disponibili in seguito."); + } + ); + + actionsButton?.addEventListener("click", () => { + if (!actionsMenu) return; + + if (!actionsMenu.hidden) { + actionsMenu.hidden = true; + actionsMenu.classList.remove("opens-up"); + actionsButton.setAttribute("aria-expanded", "false"); + return; + } + + // Sceglie il lato con più spazio, soprattutto su schermi Android. + actionsMenu.classList.remove("opens-up"); + actionsMenu.hidden = false; + + const rect = actionsButton.getBoundingClientRect(); + const menuHeight = Math.min( + actionsMenu.scrollHeight || 112, + window.innerHeight - 24 + ); + const spaceBelow = window.innerHeight - rect.bottom; + const spaceAbove = rect.top; + + if (spaceBelow < menuHeight + 12 && spaceAbove > spaceBelow) { + actionsMenu.classList.add("opens-up"); + } + + actionsButton.setAttribute("aria-expanded", "true"); + }); + + document.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || !actionsMenu || actionsMenu.hidden) { + return; + } + actionsMenu.hidden = true; + actionsMenu.classList.remove("opens-up"); + actionsButton?.setAttribute("aria-expanded", "false"); + actionsButton?.focus(); + }); + + document.addEventListener("click", (event) => { + if (actionsMenu && + !actionsMenu.hidden && + !event.target.closest("#selectionActionsBtn") && + !event.target.closest("#selectionActionsMenu")) { + actionsMenu.hidden = true; + actionsMenu.classList.remove("opens-up"); + actionsButton?.setAttribute("aria-expanded", "false"); + } + }); + }); + + window.GallerySelection = Object.freeze({ + bindTile, + clear, + isSelected, + getMode: () => mode, + getItems: () => [...selected.values()], + getCount: () => selected.size + }); +})(); diff --git a/public/js/galleryUtils.js b/public/js/galleryUtils.js new file mode 100644 index 0000000..0be4209 --- /dev/null +++ b/public/js/galleryUtils.js @@ -0,0 +1,219 @@ +// public/js/galleryUtils.js +// Ordinamento, filtri, raggruppamento e formattazione. + +(() => { + "use strict"; + + function sortByDate(items, direction = "desc") { + return items.slice().sort((a, b) => { + const da = a?.taken_at ? new Date(a.taken_at) : 0; + const db = b?.taken_at ? new Date(b.taken_at) : 0; + return direction === "asc" ? da - db : db - da; + }); + } + + function applyFilters(items) { + if (window.state?.mode === "activities") return items; + + const filter = window.currentFilter || ""; + const query = String(window.currentSearch || "") + .trim() + .toLocaleLowerCase("it-IT"); + const selectedYear = window.currentYear + ? Number(window.currentYear) + : null; + const selectedMonth = window.currentMonth + ? Number(window.currentMonth) + : null; + const selectedFolder = String(window.currentFolder || ""); + + function getFolder(item) { + return String(item?.cartella || item?.folder || "").trim(); + } + + function hasGps(item) { + const gps = item?.gps; + if (!gps) return false; + + const lat = Number(gps.lat); + const lng = Number(gps.lng); + + return Number.isFinite(lat) && + Number.isFinite(lng) && + lat >= -90 && lat <= 90 && + lng >= -180 && lng <= 180; + } + + function searchableText(item) { + return [ + item?.name, + item?.file_name, + item?.filename, + item?.originalname, + item?.path, + item?.relative_path, + item?.full_path, + item?.cartella, + item?.folder + ] + .filter(value => value !== undefined && value !== null) + .join(" ") + .toLocaleLowerCase("it-IT"); + } + + return items.filter(item => { + const mime = String(item?.mime_type || "").toLowerCase(); + + if (selectedFolder && getFolder(item) !== selectedFolder) { + return false; + } + + switch (filter) { + case "folder": + if (!(item?.cartella || item?.folder)) return false; + break; + + case "location": + if (!hasGps(item)) return false; + break; + + case "type": // Compatibilità con il vecchio pulsante. + case "images": + if (!mime.startsWith("image/")) return false; + break; + + case "videos": + if (!mime.startsWith("video/")) return false; + break; + } + + if (query && !searchableText(item).includes(query)) { + return false; + } + + if (selectedYear || selectedMonth) { + if (!item?.taken_at) return false; + + const date = new Date(item.taken_at); + if (Number.isNaN(date.getTime())) return false; + + if (selectedYear && date.getFullYear() !== selectedYear) { + return false; + } + + if (selectedMonth && date.getMonth() + 1 !== selectedMonth) { + return false; + } + } + + return true; + }); + } + + function formatDay(date) { + return date.toLocaleDateString("it-IT", { + weekday: "long", + day: "numeric", + month: "long" + }); + } + + function formatMonth(date) { + return date.toLocaleDateString("it-IT", { + month: "long", + year: "numeric" + }); + } + + function groupByDate(items, mode = "auto") { + const sections = []; + const sectionMap = new Map(); + const now = new Date(); + + function getLabel(item) { + if (mode === "folder") { + return String(item?.cartella || item?.folder || "").trim() + || "Senza cartella"; + } + + const date = item?.taken_at ? new Date(item.taken_at) : null; + if (!date || Number.isNaN(date.getTime())) return "Senza data"; + + const diffDays = Math.floor((now - date) / 86400000); + + if (mode === "day") return formatDay(date); + if (mode === "month") return formatMonth(date); + if (mode === "year") return String(date.getFullYear()); + + if (diffDays === 0) return "Oggi"; + if (diffDays === 1) return "Ieri"; + if (diffDays <= 7) return "Questa settimana"; + if (diffDays <= 14) return "La settimana scorsa"; + if (diffDays <= 30) return "Questo mese"; + if (diffDays <= 60) return "Mese scorso"; + if (date.getFullYear() === now.getFullYear()) return formatMonth(date); + return String(date.getFullYear()); + } + + for (const item of items) { + const label = getLabel(item); + let section = sectionMap.get(label); + + if (!section) { + section = { label, items: [] }; + sectionMap.set(label, section); + sections.push(section); + } + + section.items.push(item); + } + + return sections; + } + + function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + function formatDurationShort(ms) { + const totalSec = Math.round(ms / 1000); + const hours = Math.floor(totalSec / 3600); + const minutes = Math.floor((totalSec % 3600) / 60); + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; + } + + function formatActivityMeta(item) { + const parts = []; + + if (item.sport) parts.push(item.sport); + if (Number.isFinite(Number(item.distance_meters))) { + parts.push(`${(Number(item.distance_meters) / 1000).toFixed(1)} km`); + } + if (Number.isFinite(Number(item.duration_millis))) { + parts.push(formatDurationShort(Number(item.duration_millis))); + } + + return parts.join(" · "); + } + + window.GalleryUtils = Object.freeze({ + sortByDate, + applyFilters, + groupByDate, + formatDay, + formatMonth, + escapeHtml, + formatDurationShort, + formatActivityMeta + }); + + // Compatibilità con gli script esistenti. + window.sortByDate = sortByDate; + window.applyFilters = applyFilters; + window.groupByDate = groupByDate; +})(); diff --git a/public/js/galleryZoom.js b/public/js/galleryZoom.js new file mode 100644 index 0000000..25fdce0 --- /dev/null +++ b/public/js/galleryZoom.js @@ -0,0 +1,101 @@ +// Zoom indipendente per foto e attività. +(() => { + "use strict"; + + const STORAGE = { + photos: "galleryZoomPhotos", + activities: "galleryZoomActivities" + }; + + const MIN_SCALE = 0.65; + const MAX_SCALE = 2.5; + const STEP = 0.1; + + function getScale(mode) { + const value = Number(localStorage.getItem(STORAGE[mode])); + return Number.isFinite(value) && value >= MIN_SCALE && value <= MAX_SCALE + ? value + : 1; + } + + function setScale(mode, value) { + const scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, value)); + localStorage.setItem(STORAGE[mode], String(scale)); + window.GalleryRenderer?.refreshLayout?.(); + } + + function currentMode() { + return window.state?.mode === "activities" ? "activities" : "photos"; + } + + function getMinTile(activities, base) { + return base * getScale(activities ? "activities" : "photos"); + } + + // Desktop: Ctrl + rotellina. + window.addEventListener("wheel", (event) => { + if (!event.ctrlKey) return; + + const gallery = document.getElementById("gallery"); + if (!gallery || !gallery.contains(event.target)) return; + + event.preventDefault(); + const mode = currentMode(); + const direction = event.deltaY < 0 ? 1 : -1; + setScale(mode, getScale(mode) + direction * STEP); + }, { passive: false }); + + // Smartphone/tablet: pinch a due dita nella galleria. + let pinchStartDistance = 0; + let pinchStartScale = 1; + let pinchMode = "photos"; + let pinchActive = false; + + function distance(touches) { + const dx = touches[0].clientX - touches[1].clientX; + const dy = touches[0].clientY - touches[1].clientY; + return Math.hypot(dx, dy); + } + + function isGalleryTouch(event) { + const gallery = document.getElementById("gallery"); + return gallery && gallery.contains(event.target); + } + + document.addEventListener("touchstart", (event) => { + if (event.touches.length !== 2 || !isGalleryTouch(event)) return; + + pinchActive = true; + pinchMode = currentMode(); + pinchStartDistance = distance(event.touches); + pinchStartScale = getScale(pinchMode); + }, { passive: true }); + + document.addEventListener("touchmove", (event) => { + if (!pinchActive || event.touches.length !== 2 || !isGalleryTouch(event)) return; + + event.preventDefault(); + const currentDistance = distance(event.touches); + if (pinchStartDistance <= 0) return; + + const ratio = currentDistance / pinchStartDistance; + const scale = Math.round((pinchStartScale * ratio) * 100) / 100; + const bounded = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale)); + + localStorage.setItem(STORAGE[pinchMode], String(bounded)); + window.GalleryRenderer?.refreshLayout?.(); + }, { passive: false }); + + document.addEventListener("touchend", (event) => { + if (event.touches.length < 2) pinchActive = false; + }, { passive: true }); + + document.addEventListener("touchcancel", () => { + pinchActive = false; + }, { passive: true }); + + window.GalleryZoom = Object.freeze({ + getMinTile, + getScale: (mode) => getScale(mode) + }); +})(); diff --git a/public/js/infoPanel.js b/public/js/infoPanel.js new file mode 100644 index 0000000..2e825a6 --- /dev/null +++ b/public/js/infoPanel.js @@ -0,0 +1,543 @@ +// public/js/infoPanel.js +// =============================== +// PANNELLO INFO - foto + attività + mappa +// =============================== + +const infoPanel = document.getElementById("infoPanel"); +let infoMapInstance = null; + +// ------------------------------- +// Helpers UI / stato +// ------------------------------- +function isPanelOpen() { + return ( + infoPanel.classList.contains("open") || + infoPanel.getAttribute("aria-hidden") === "false" || + infoPanel.getAttribute("data-open") === "1" || + infoPanel.style.display === "block" + ); +} + +function markButtonActive(active) { + const photoBtn = document.getElementById("modalInfoBtn"); + const activityBtn = document.getElementById("activityInfoBtn"); + + if (photoBtn) { + photoBtn.classList.toggle("active", !!active); + } + + if (activityBtn) { + activityBtn.classList.toggle("active", !!active); + } +} + +function escapeInfoHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function fmtInt(value) { + const n = Number(value); + return Number.isFinite(n) ? String(Math.round(n)) : "-"; +} + +function fmtKm(meters) { + const n = Number(meters); + return Number.isFinite(n) ? `${(n / 1000).toFixed(1)} km` : "-"; +} + +function fmtMeters(value) { + const n = Number(value); + return Number.isFinite(n) ? `${Math.round(n)} m` : "-"; +} + +function fmtSpeedMps(value) { + const n = Number(value); + return Number.isFinite(n) ? `${(n * 3.6).toFixed(1)} km/h` : "-"; +} + +function fmtPace(secPerKm) { + const n = Number(secPerKm); + + if (!Number.isFinite(n) || n <= 0) { + return "-"; + } + + const min = Math.floor(n / 60); + const sec = Math.round(n % 60); + + return `${min}:${String(sec).padStart(2, "0")} /km`; +} + +function fmtDuration(ms) { + const n = Number(ms); + + if (!Number.isFinite(n) || n < 0) { + return "-"; + } + + const totalSec = Math.round(n / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + + if (h > 0) { + return `${h}h ${m}m ${s}s`; + } + + return `${m}m ${s}s`; +} + +function fmtDateFromMillis(ms) { + const n = Number(ms); + + if (!Number.isFinite(n)) { + return "-"; + } + + try { + return new Date(n).toLocaleString("it-IT"); + } catch { + return "-"; + } +} + +function normalizeGpsFromItem(item) { + if (!item) { + return { lat: "-", lng: "-" }; + } + + if (item.gps && item.gps.lat && item.gps.lng) { + return item.gps; + } + + const lat = + item.center_latitude ?? + item.latitude ?? + item.lat ?? + item.min_latitude; + + const lng = + item.center_longitude ?? + item.longitude ?? + item.lng ?? + item.min_longitude; + + if ( + Number.isFinite(Number(lat)) && + Number.isFinite(Number(lng)) + ) { + return { + lat: Number(lat), + lng: Number(lng) + }; + } + + return { lat: "-", lng: "-" }; +} + +function isActivityItem(item) { + if (!item) return false; + + const format = String( + item.format || + item.file_extension || + item.mime_type || + item.file_name || + item.name || + "" + ).toLowerCase(); + + return ( + window.state?.mode === "activities" || + format.includes("fit") || + format.includes("gpx") || + format.includes("tcx") || + item.distance_meters != null || + item.duration_millis != null || + item.track_preview != null + ); +} + +// ------------------------------- +// RENDER FOTO +// ------------------------------- +function renderInfoPhoto(photo) { + const gps = photo.gps || { lat: "-", lng: "-", alt: "-" }; + const loc = photo.location || {}; + const folder = photo.cartella || "-"; + + infoPanel.innerHTML = ` + +

Informazioni

+ +
Nome: ${escapeInfoHtml(photo.name ?? "-")}
+
Data: ${escapeInfoHtml(photo.taken_at ?? "-")}
+ +
Latitudine: ${escapeInfoHtml(gps.lat ?? "-")}
+
Longitudine: ${escapeInfoHtml(gps.lng ?? "-")}
+
Altitudine: ${escapeInfoHtml(gps.alt ?? "-")} m
+ +
Dimensioni: ${escapeInfoHtml(photo.width ?? "-")} × ${escapeInfoHtml(photo.height ?? "-")}
+ +
Peso: ${ + photo.size_bytes + ? `${(Number(photo.size_bytes) / 1024 / 1024).toFixed(2)} MB` + : "-" + }
+ +
Tipo: ${escapeInfoHtml(photo.mime_type ?? "-")}
+
Cartella: ${escapeInfoHtml(folder)}
+ +
+ +

Mappa

+ ${ + gps.lat !== "-" && gps.lng !== "-" + ? '
' + : '
Coordinate non disponibili
' + } + +
+ +

Location

+ ${loc.continent ? `
Continente: ${escapeInfoHtml(loc.continent)}
` : ""} + ${loc.country ? `
Nazione: ${escapeInfoHtml(loc.country)}
` : ""} + ${loc.region ? `
Regione: ${escapeInfoHtml(loc.region)}
` : ""} + ${loc.city ? `
Città: ${escapeInfoHtml(loc.city)}
` : ""} + ${loc.address ? `
Indirizzo: ${escapeInfoHtml(loc.address)}
` : ""} + ${loc.postcode ? `
CAP: ${escapeInfoHtml(loc.postcode)}
` : ""} + ${loc.county_code ? `
Provincia: ${escapeInfoHtml(loc.county_code)}
` : ""} + ${loc.timezone ? `
Timezone: ${escapeInfoHtml(loc.timezone)}
` : ""} + ${loc.time ? `
Offset: ${escapeInfoHtml(loc.time)}
` : ""} + `; + + renderMap(gps); +} + +// ------------------------------- +// RENDER ATTIVITÀ +// ------------------------------- +function renderInfoActivity(a) { + const gps = normalizeGpsFromItem(a); + + const name = + a.display_name || + a.route_name || + a.title || + a.file_name || + a.name || + "-"; + + infoPanel.innerHTML = ` + +

Attività

+ +
Nome: ${escapeInfoHtml(name)}
+
Sport: ${escapeInfoHtml(a.sport ?? "-")}
+
Tipo attività: ${escapeInfoHtml(a.activity_type ?? "-")}
+
Formato: ${escapeInfoHtml(a.format || a.file_extension || "-")}
+ +
+ +

Prestazione

+ +
Distanza: ${fmtKm(a.distance_meters)}
+
Durata: ${fmtDuration(a.duration_millis)}
+
Dislivello positivo: ${fmtMeters(a.elevation_gain_meters)}
+
Dislivello negativo: ${fmtMeters(a.elevation_loss_meters)}
+
Quota minima: ${fmtMeters(a.min_elevation_meters)}
+
Quota massima: ${fmtMeters(a.max_elevation_meters)}
+ +
Velocità media: ${fmtSpeedMps(a.average_speed_mps)}
+
Velocità massima: ${fmtSpeedMps(a.max_speed_mps)}
+
Passo medio: ${fmtPace(a.average_pace_sec_per_km)}
+ +
+ +

Frequenza, cadenza, potenza

+ +
FC media: ${fmtInt(a.average_heart_rate)} bpm
+
FC massima: ${fmtInt(a.max_heart_rate)} bpm
+
Cadenza media: ${fmtInt(a.average_cadence)}
+
Cadenza massima: ${fmtInt(a.max_cadence)}
+
Calorie: ${fmtInt(a.calories)} kcal
+
Potenza media: ${fmtInt(a.power_avg)} W
+
Potenza massima: ${fmtInt(a.power_max)} W
+ +
+ +

Tempo

+ +
Inizio: ${fmtDateFromMillis(a.start_time_millis)}
+
Fine: ${fmtDateFromMillis(a.end_time_millis)}
+
Ultima modifica file: ${fmtDateFromMillis(a.last_modified_millis)}
+ +
+ +

Mappa

+ ${ + gps.lat !== "-" && gps.lng !== "-" + ? '
' + : '
Coordinate non disponibili
' + } + +
+ +

File e sorgente

+ +
File: ${escapeInfoHtml(a.file_name ?? "-")}
+
Cartella: ${escapeInfoHtml(a.cartella ?? "-")}
+
Percorso: ${escapeInfoHtml(a.path ?? "-")}
+ +
Dimensione: ${ + a.size_bytes + ? `${(Number(a.size_bytes) / 1024 / 1024).toFixed(2)} MB` + : "-" + }
+ +
Dispositivo: ${ + escapeInfoHtml( + [a.device_manufacturer, a.device_model] + .filter(Boolean) + .join(" ") || "-" + ) + }
+ +
App sorgente: ${escapeInfoHtml(a.source_app ?? "-")}
+ +
+ +

Traccia

+ +
Punti: ${fmtInt(a.point_count)}
+
Segmenti: ${fmtInt(a.segment_count)}
+
Lap: ${fmtInt(a.lap_count)}
+
Centro: ${ + gps.lat !== "-" && gps.lng !== "-" + ? `${gps.lat}, ${gps.lng}` + : "-" + }
+ `; + + renderMap(gps); +} + +// ------------------------------- +// MAPPA INFO PANEL +// ------------------------------- +function renderMap(gps) { + try { + infoMapInstance?.remove(); + } catch {} + + infoMapInstance = null; + + if (gps.lat === "-" || gps.lng === "-") return; + + setTimeout(() => { + try { + const lat = Number(gps.lat); + const lng = Number(gps.lng); + + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; + + infoMapInstance = L.map("infoMap", { + zoomControl: false, + attributionControl: false + }).setView([lat, lng], 13); + + L.tileLayer( + "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + { + maxZoom: 19 + } + ).addTo(infoMapInstance); + + L.marker([lat, lng]).addTo(infoMapInstance); + + setTimeout(() => { + infoMapInstance?.invalidateSize?.(); + }, 100); + } catch (err) { + console.warn("Errore creazione mappa info:", err); + } + }, 80); +} + +// ------------------------------- +// RENDER DISPATCHER +// ------------------------------- +function renderInfo(item) { + if (!item) return; + + if (isActivityItem(item)) { + renderInfoActivity(item); + } else { + renderInfoPhoto(item); + } +} + +// ------------------------------- +// API pubbliche +// ------------------------------- +window.openInfoPanel = function(item) { + renderInfo(item || window.currentPhoto || window.currentActivity); + + infoPanel.classList.add("open"); + infoPanel.setAttribute("aria-hidden", "false"); + infoPanel.setAttribute("data-open", "1"); + + markButtonActive(true); +}; + +window.closeInfoPanel = function() { + infoPanel.classList.remove("open"); + infoPanel.setAttribute("aria-hidden", "true"); + infoPanel.setAttribute("data-open", "0"); + + markButtonActive(false); + + try { + infoMapInstance?.remove(); + } catch {} + + infoMapInstance = null; +}; + +window.toggleInfoPanel = function(item) { + if (isPanelOpen()) { + window.closeInfoPanel(); + } else { + window.openInfoPanel(item || window.currentPhoto || window.currentActivity); + } +}; + +// ------------------------------- +// Chiudi pannello cliccando fuori +// ------------------------------- +/*document.addEventListener("click", event => { + if (!isPanelOpen()) return; + + const insidePanel = infoPanel.contains(event.target); + + const isPhotoInfoButton = + event.target.id === "modalInfoBtn" || + event.target.closest?.("#modalInfoBtn"); + + const isActivityInfoButton = + event.target.id === "activityInfoBtn" || + event.target.closest?.("#activityInfoBtn"); + + if (!insidePanel && !isPhotoInfoButton && !isActivityInfoButton) { + window.closeInfoPanel(); + } +}); +*/ +document.addEventListener("click", event => { + if (!isPanelOpen()) return; + + const insidePanel = infoPanel.contains(event.target); + + const isPhotoInfoButton = + event.target.closest?.("#modalInfoBtn"); + + const isActivityInfoButton = + event.target.closest?.("#activityInfoBtn"); + + const insideMedia = + event.target.closest?.("#modalMediaContainer"); + + if (insidePanel || isPhotoInfoButton || isActivityInfoButton) { + return; + } + + if (insideMedia) { + window.closeInfoPanel(); + return; + } + + window.closeInfoPanel(); +}); + +// Chiudi info panel cliccando sul contenuto media del modal +document.getElementById("modalMediaContainer") +?.addEventListener("click", event => { + + if (!isPanelOpen()) return; + + const isVideo = event.target.closest("video"); + const isImage = event.target.closest("img"); + + if (isVideo || isImage) { + window.closeInfoPanel(); + } +}); +// ------------------------------- +// Auto-refresh su cambio media nel modal foto +// ------------------------------- +(() => { + const mediaContainer = document.getElementById("modalMediaContainer"); + if (!mediaContainer) return; + + const refreshIfOpen = () => { + if (!isPanelOpen()) return; + + const item = window.currentPhoto || window.currentActivity; + + if (item) { + renderInfo(item); + } + }; + + const mo = new MutationObserver(() => { + setTimeout(refreshIfOpen, 0); + }); + + mo.observe(mediaContainer, { + childList: true + }); + + document.getElementById("modalPrev")?.addEventListener("click", () => + setTimeout(refreshIfOpen, 0) + ); + + document.getElementById("modalNext")?.addEventListener("click", () => + setTimeout(refreshIfOpen, 0) + ); + + document.addEventListener("keydown", event => { + if ( + event.key === "ArrowLeft" || + event.key === "ArrowRight" + ) { + setTimeout(refreshIfOpen, 0); + } + }); +})(); + + + +/* +document + .getElementById("modalMediaContainer") + ?.addEventListener("click", () => { + if (isPanelOpen()) { + window.closeInfoPanel(); + } + }, true); +*/ + + + +document.addEventListener("click", event => { + const btn = event.target.closest(".info-close-btn"); + + if (btn) { + window.closeInfoPanel?.(); + } +}); diff --git a/public/js/lib/vendor.js b/public/js/lib/vendor.js new file mode 100644 index 0000000..b0ba96a --- /dev/null +++ b/public/js/lib/vendor.js @@ -0,0 +1,9 @@ +(function () { + + window.GP = window.GP || {}; + + GP.libs = GP.libs || {}; + + GP.libs.RBush = window.RBush; + +})(); diff --git a/public/js/login.js b/public/js/login.js new file mode 100644 index 0000000..7b9d550 --- /dev/null +++ b/public/js/login.js @@ -0,0 +1,76 @@ +// =============================== +// LOGIN.JS — Gestione form login +// =============================== + +(() => { + const form = document.getElementById("loginForm"); + const userInput = document.getElementById("loginUser"); + const passInput = document.getElementById("loginPass"); + const submitBtn = document.getElementById("loginSubmit"); + const errorBox = document.getElementById("loginError"); + + const redirectUrl = + form?.getAttribute("data-redirect") || + new URLSearchParams(location.search).get("redirect") || + "/"; + + function showError(msg) { + if (!errorBox) return; + errorBox.textContent = msg; + errorBox.classList.add("visible"); + } + + function clearError() { + if (!errorBox) return; + errorBox.textContent = ""; + errorBox.classList.remove("visible"); + } + + function setLoading(loading) { + submitBtn.disabled = loading; + submitBtn.setAttribute("aria-busy", loading ? "true" : "false"); + } + + async function handleSubmit(e) { + e.preventDefault(); + clearError(); + + const email = userInput.value.trim(); + const password = passInput.value; + + if (!email || !password) { + showError("Inserisci email e password"); + return; + } + + setLoading(true); + + try { +/* await window.AppAuth.login(email, password); + window.location.assign(redirectUrl); +*/ +await window.AppAuth.login(email, password); + +console.log("[LOGIN DEBUG]", { + token: sessionStorage.getItem("token"), + logged: window.AppAuth.isLoggedIn(), + user: window.AppAuth.getUser(), + redirectUrl +}); + +// BLOCCATO TEMPORANEAMENTE + window.location.assign(redirectUrl); + } catch (err) { + showError("Credenziali non valide"); + } finally { + setLoading(false); + } + } + + function init() { + if (!form) return; + form.addEventListener("submit", handleSubmit); + } + + document.addEventListener("DOMContentLoaded", init); +})(); diff --git a/public/js/logout.js b/public/js/logout.js new file mode 100644 index 0000000..841b332 --- /dev/null +++ b/public/js/logout.js @@ -0,0 +1,532 @@ +// =============================== +// LOGOUT — pulizia token + revoca + auto-logout JWT +// =============================== + +(() => { + const AUTH_LOGOUT_ENDPOINT = "/auth/logout"; + const TOKEN_KEYS = ["token", "access_token", "refresh_token"]; + + const LONG_PRESS_MS = 650; + const LONG_PRESS_MOVE_PX = 12; + + let contextMenu = null; + let suppressNextLogoutClick = false; + + // ------------------------------- + // Helpers token + // ------------------------------- + function getToken() { + try { + return ( + localStorage.getItem("token") || + localStorage.getItem("access_token") || + "" + ); + } catch { + return ""; + } + } + + function clearTokens() { + try { + TOKEN_KEYS.forEach(k => localStorage.removeItem(k)); + } catch {} + } + + // ------------------------------- + // Logout lato server + // ------------------------------- + async function serverLogout(token) { + try { + const headers = token ? { Authorization: `Bearer ${token}` } : {}; + await fetch(AUTH_LOGOUT_ENDPOINT, { method: "POST", headers }); + } catch (err) { + console.warn("Logout server fallito (ignoro):", err); + } + } + + // ------------------------------- + // Redirect + // ------------------------------- + function getRedirectUrl() { + const btn = document.querySelector("[data-logout]"); + return btn?.getAttribute("data-redirect") || "/"; + } + + function redirect(url) { + window.location.assign(url || "/"); + } + + // ------------------------------- + // Auto-logout alla scadenza JWT + // ------------------------------- + let autoLogoutTimer = null; + + function scheduleAutoLogout() { + clearTimeout(autoLogoutTimer); + + const token = getToken(); + if (!token) return; + + const payload = parseJwt(token); // usa la funzione globale già esistente + const expSec = payload?.exp; + if (!expSec) return; + + const msToExp = expSec * 1000 - Date.now(); + if (msToExp <= 0) { + doLogout({ doRedirect: true }); + return; + } + + autoLogoutTimer = setTimeout(() => { + doLogout({ doRedirect: true }); + }, msToExp); + } + + // ------------------------------- + // UI helpers + // ------------------------------- + function setButtonsDisabled(disabled) { + document.querySelectorAll("[data-logout]").forEach(btn => { + btn.disabled = disabled; + btn.setAttribute("aria-busy", disabled ? "true" : "false"); + }); + } + + // ------------------------------- + // Azione principale + // ------------------------------- + async function doLogout({ doRedirect = true } = {}) { + const token = getToken(); + setButtonsDisabled(true); + + await serverLogout(token); + clearTokens(); + + try { + window.dispatchEvent(new CustomEvent("logout:success")); + } catch {} + + if (doRedirect) redirect(getRedirectUrl()); + + setButtonsDisabled(false); + } + + // ------------------------------- + // Menu contestuale Logout + // ------------------------------- + + function currentGalleryMode() { + return window.state?.mode === "activities" + ? "activities" + : "photos"; + } + + function closeLogoutContextMenu() { + if (contextMenu) { + contextMenu.remove(); + contextMenu = null; + } + + document.removeEventListener( + "pointerdown", + onContextOutsidePointer, + true + ); + + document.removeEventListener( + "keydown", + onContextKeyDown, + true + ); + } + + function onContextOutsidePointer(event) { + if (!contextMenu) return; + + if (contextMenu.contains(event.target)) { + return; + } + + if (event.target.closest?.("[data-logout]")) { + return; + } + + closeLogoutContextMenu(); + } + + function onContextKeyDown(event) { + if (event.key === "Escape") { + event.preventDefault(); + closeLogoutContextMenu(); + } + } + + async function authFetch(url, options = {}) { + const token = getToken(); + + const headers = { + ...(options.headers || {}) + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(url, { + ...options, + headers + }); + + if (!response.ok) { + let detail = ""; + + try { + const data = await response.json(); + detail = data?.error || data?.message || ""; + } catch {} + + throw new Error( + detail || + `HTTP ${response.status}` + ); + } + + return response; + } + + async function startContextScan(mode) { + const endpoint = + mode === "activities" + ? "/scanActivities" + : "/scan"; + + const label = + mode === "activities" + ? "attivita" + : "foto"; + + closeLogoutContextMenu(); + + console.log( + `[LogoutMenu] Avvio scansione ${label}: ${endpoint}` + ); + + try { + await authFetch(endpoint); + + console.log( + `[LogoutMenu] Scansione ${label} avviata` + ); + + } catch (error) { + console.error( + `[LogoutMenu] Errore scansione ${label}:`, + error + ); + + } + } + + function createContextMenuButton(label, action, danger = false) { + const button = document.createElement("button"); + + button.type = "button"; + button.textContent = label; + + Object.assign(button.style, { + display: "block", + width: "100%", + border: "0", + margin: "0", + padding: "12px 16px", + textAlign: "left", + background: "transparent", + color: danger ? "#ff8d8d" : "inherit", + font: "inherit", + cursor: "pointer", + whiteSpace: "nowrap", + borderRadius: "8px" + }); + + button.addEventListener("pointerenter", () => { + button.style.background = + "rgba(255,255,255,0.10)"; + }); + + button.addEventListener("pointerleave", () => { + button.style.background = "transparent"; + }); + + button.addEventListener("click", async event => { + event.preventDefault(); + event.stopPropagation(); + + await action(); + }); + + return button; + } + + function showLogoutContextMenu(btn) { + closeLogoutContextMenu(); + + const mode = currentGalleryMode(); + + const menu = document.createElement("div"); + contextMenu = menu; + + menu.id = "logoutContextMenu"; + menu.setAttribute("role", "menu"); + + Object.assign(menu.style, { + position: "fixed", + zIndex: "100000", + minWidth: "170px", + padding: "6px", + borderRadius: "12px", + background: "rgba(28,28,30,0.98)", + color: "#fff", + boxShadow: "0 10px 30px rgba(0,0,0,0.38)", + border: "1px solid rgba(255,255,255,0.12)", + backdropFilter: "blur(12px)" + }); + + const scanLabel = + mode === "activities" + ? "Scan attivita" + : "Scan foto"; + + menu.appendChild( + createContextMenuButton( + scanLabel, + () => startContextScan(mode) + ) + ); + + const separator = document.createElement("div"); + + Object.assign(separator.style, { + height: "1px", + margin: "5px 4px", + background: "rgba(255,255,255,0.12)" + }); + + menu.appendChild(separator); + + menu.appendChild( + createContextMenuButton( + "Logout", + async () => { + closeLogoutContextMenu(); + await doLogout({ + doRedirect: true + }); + }, + true + ) + ); + + document.body.appendChild(menu); + + const rect = btn.getBoundingClientRect(); + const menuRect = menu.getBoundingClientRect(); + + let left = + rect.right - menuRect.width; + + let top = + rect.bottom + 8; + + if ( + top + menuRect.height > + window.innerHeight - 8 + ) { + top = + rect.top - + menuRect.height - + 8; + } + + left = Math.max( + 8, + Math.min( + left, + window.innerWidth - + menuRect.width - + 8 + ) + ); + + top = Math.max( + 8, + Math.min( + top, + window.innerHeight - + menuRect.height - + 8 + ) + ); + + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + + setTimeout(() => { + document.addEventListener( + "pointerdown", + onContextOutsidePointer, + true + ); + + document.addEventListener( + "keydown", + onContextKeyDown, + true + ); + }, 0); + } + + function bindLogoutLongPress(btn) { + let timer = null; + let startX = 0; + let startY = 0; + let activePointerId = null; + let longPressTriggered = false; + + function clearLongPressTimer() { + if (timer) { + clearTimeout(timer); + timer = null; + } + } + + btn.addEventListener("pointerdown", event => { + if (btn.disabled) return; + + if ( + event.pointerType === "mouse" && + event.button !== 0 + ) { + return; + } + + closeLogoutContextMenu(); + + activePointerId = event.pointerId; + startX = event.clientX; + startY = event.clientY; + longPressTriggered = false; + + timer = setTimeout(() => { + timer = null; + longPressTriggered = true; + suppressNextLogoutClick = true; + + if (navigator.vibrate) { + try { + navigator.vibrate(25); + } catch {} + } + + showLogoutContextMenu(btn); + }, LONG_PRESS_MS); + }); + + btn.addEventListener("pointermove", event => { + if ( + activePointerId !== event.pointerId || + !timer + ) { + return; + } + + const distance = Math.hypot( + event.clientX - startX, + event.clientY - startY + ); + + if (distance > LONG_PRESS_MOVE_PX) { + clearLongPressTimer(); + } + }); + + btn.addEventListener("pointerup", event => { + if ( + activePointerId !== null && + activePointerId !== event.pointerId + ) { + return; + } + + clearLongPressTimer(); + activePointerId = null; + + if (longPressTriggered) { + event.preventDefault(); + event.stopPropagation(); + } + }); + + btn.addEventListener("pointercancel", () => { + clearLongPressTimer(); + activePointerId = null; + longPressTriggered = false; + }); + + btn.addEventListener("contextmenu", event => { + event.preventDefault(); + + suppressNextLogoutClick = true; + showLogoutContextMenu(btn); + }); + } + + // ------------------------------- + // Bind pulsanti + // ------------------------------- + function bindLogoutButtons() { + document.querySelectorAll("[data-logout]").forEach(btn => { + btn.addEventListener("click", async (e) => { + e.preventDefault(); + + if (suppressNextLogoutClick) { + suppressNextLogoutClick = false; + return; + } + + if (btn.disabled) return; + + closeLogoutContextMenu(); + await doLogout({ doRedirect: true }); + }); + + bindLogoutLongPress(btn); + }); + } + + // ------------------------------- + // Public API + // ------------------------------- + window.AppAuth = Object.freeze({ + logout: (opts) => doLogout(opts), + isLoggedIn: () => !!getToken() + }); + + // ------------------------------- + // Init + // ------------------------------- + function init() { + bindLogoutButtons(); + scheduleAutoLogout(); + + document.querySelectorAll("[data-logout]").forEach(btn => { + if (!window.AppAuth.isLoggedIn()) btn.style.display = "none"; + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..a7c8fcd --- /dev/null +++ b/public/js/main.js @@ -0,0 +1,55 @@ +// =============================== +// main.js — Bootstrap dell'app +// =============================== + +async function initGallery() { + console.log("=== INIT GALLERY ==="); + + // 1) CARICO CONFIG + console.log("[initGallery] Chiamo /config..."); + let cfg; + try { + cfg = await fetch("/config").then(r => r.json()); + } catch (e) { + console.error("❌ Errore caricamento /config:", e); + return; + } + + console.log("[initGallery] /config RISPOSTA:", cfg); + + // 2) PARAMETRI GLOBALI + window.PATH_FULL = cfg.pathFull; + const payload = parseJwt(localStorage.getItem("token")); + const user = payload?.name || "Common"; + const refreshSeconds = cfg.galleryRefreshSeconds || 30; + + console.log("[initGallery] Utente:", user); + console.log("[initGallery] refreshSeconds:", refreshSeconds); + + // 3) CARICO CACHE LOCALE (se esiste) + const cached = loadLocalState(); + if (cached.length > 0) { + console.log(`[initGallery] Cache locale caricata: ${cached.length} foto`); + refreshGallery(); + } + + // 4) SYNC INIZIALE + console.log("[initGallery] Avvio incrementalSync() iniziale..."); + await incrementalSync(); + console.log("[initGallery] incrementalSync() COMPLETATO"); + + // 5) POLLING DI SICUREZZA + if (refreshSeconds > 0) { + console.log(`[initGallery] Polling attivo ogni ${refreshSeconds} secondi...`); + + setInterval(async () => { + console.log(">>> TIMER: incrementalSync()"); + await incrementalSync(); + }, refreshSeconds * 1000); + } else { + console.log("[initGallery] Polling disattivato"); + } +} + +// Bootstrap +window.addEventListener("DOMContentLoaded", initGallery); diff --git a/public/js/map/activities/activityClusters.js b/public/js/map/activities/activityClusters.js new file mode 100644 index 0000000..786d03a --- /dev/null +++ b/public/js/map/activities/activityClusters.js @@ -0,0 +1,949 @@ +// public/js/map/activities/activityClusters.js +// =================================== +// MAPBOX ACTIVITY CLUSTERS +// =================================== + +(() => { + "use strict"; + + let mapboxMap = null; + + const clusterMarkers = new Map(); + + let renderToken = 0; + + const SOURCE_ID = "activities"; + const CLUSTER_LAYER_ID = "activities-clusters"; + const COUNT_LAYER_ID = "activities-cluster-count"; + + // =================================== + // SET MAP + // =================================== + +function setMap(map) { + if (!map) { + return; + } + + if ( + mapboxMap && + mapboxMap !== map + ) { + clear(); + } + + mapboxMap = map; + + console.log( + "[ActivityClusters] setMap", + ); +} + +// =================================== +// CREA LAYER CLUSTER +// =================================== + +function createLayer() { + if (!mapboxMap) { + return; + } + + if (!mapboxMap.isStyleLoaded()) { + return; + } + + if (!mapboxMap.getSource(SOURCE_ID)) { + console.warn( + "[ActivityClusters] source activities non presente", + ); + + return; + } + + // --------------------------------- + // CLUSTER LAYER + // --------------------------------- + + if (!mapboxMap.getLayer(CLUSTER_LAYER_ID)) { + console.log( + "[ActivityClusters] creo layer cluster", + ); + + mapboxMap.addLayer({ + id: CLUSTER_LAYER_ID, + + type: "circle", + + source: SOURCE_ID, + + filter: [ + "has", + "point_count", + ], + + paint: { + /* + * Il layer Mapbox deve rimanere + * renderizzato perché viene usato da + * queryRenderedFeatures(). + * + * Il cerchio nativo viene nascosto + * perché il cluster visibile è un + * marker HTML personalizzato. + */ + + "circle-color": "#ff3333", + + "circle-radius": [ + "step", + ["get", "point_count"], + 22, + 10, + 28, + 50, + 38, + ], + + "circle-stroke-color": + "#ffffff", + + "circle-stroke-width": 3, + + "circle-opacity": 0.001, + + "circle-stroke-opacity": 0, + }, + }); + } + + // --------------------------------- + // COUNT LAYER + // --------------------------------- + + if (!mapboxMap.getLayer(COUNT_LAYER_ID)) { + console.log( + "[ActivityClusters] creo layer conteggio", + ); + + mapboxMap.addLayer({ + id: COUNT_LAYER_ID, + + type: "symbol", + + source: SOURCE_ID, + + filter: [ + "has", + "point_count", + ], + + layout: { + "text-field": + "{point_count_abbreviated}", + + "text-size": 14, + + /* + * Permette al simbolo di essere + * calcolato senza sovrapporsi ad + * altri simboli Mapbox. + * + * Il testo rimane comunque invisibile. + */ + "text-allow-overlap": true, + + "text-ignore-placement": true, + }, + + paint: { + /* + * Il conteggio visibile viene mostrato + * dal marker HTML personalizzato. + */ + + "text-color": "#ffffff", + + "text-opacity": 0, + }, + }); + } + + // --------------------------------- + // FORZA INVISIBILITÀ + // --------------------------------- + + /* + * Questo passaggio è importante quando + * createLayer() viene richiamata su layer + * già creati in precedenza. + * + * In quel caso addLayer() non viene eseguito, + * quindi impostiamo esplicitamente le proprietà + * per evitare che rimanga visibile il vecchio + * cerchio Mapbox. + */ + +// --------------------------------- +// FORZA INVISIBILITÀ +// --------------------------------- + +if ( + mapboxMap.getLayer( + CLUSTER_LAYER_ID, + ) +) { + const circleOpacity = + mapboxMap.getPaintProperty( + CLUSTER_LAYER_ID, + "circle-opacity", + ); + + if (circleOpacity !== 0.001) { + mapboxMap.setPaintProperty( + CLUSTER_LAYER_ID, + "circle-opacity", + 0.001, + ); + } + + const strokeOpacity = + mapboxMap.getPaintProperty( + CLUSTER_LAYER_ID, + "circle-stroke-opacity", + ); + + if (strokeOpacity !== 0) { + mapboxMap.setPaintProperty( + CLUSTER_LAYER_ID, + "circle-stroke-opacity", + 0, + ); + } +} + +if ( + mapboxMap.getLayer( + COUNT_LAYER_ID, + ) +) { + const textOpacity = + mapboxMap.getPaintProperty( + COUNT_LAYER_ID, + "text-opacity", + ); + + if (textOpacity !== 0) { + mapboxMap.setPaintProperty( + COUNT_LAYER_ID, + "text-opacity", + 0, + ); + } +} + +console.log( + "[ActivityClusters] layer creati e nascosti", + { + clusterLayer: + !!mapboxMap.getLayer( + CLUSTER_LAYER_ID, + ), + + countLayer: + !!mapboxMap.getLayer( + COUNT_LAYER_ID, + ), + }, +); + +} + +// =================================== +// CLUSTER VISIBILI +// =================================== + +function getRenderedClusters() { + if (!mapboxMap) { + return []; + } + + if (!mapboxMap.isStyleLoaded()) { + return []; + } + + if ( + !mapboxMap.getSource( + SOURCE_ID, + ) + ) { + return []; + } + + if ( + !mapboxMap.getLayer( + CLUSTER_LAYER_ID, + ) + ) { + return []; + } + + let rendered = []; + + try { + /* + * Interroghiamo il layer Mapbox invisibile. + * + * queryRenderedFeatures() restituisce + * solamente i cluster attualmente + * renderizzati nella viewport. + */ + rendered = + mapboxMap.queryRenderedFeatures( + { + layers: [ + CLUSTER_LAYER_ID, + ], + }, + ); + } catch (error) { + console.warn( + "[ActivityClusters] errore queryRenderedFeatures", + error, + ); + + return []; + } + + const clusters = []; + const seen = new Set(); + + for (const feature of rendered) { + if (!feature) { + continue; + } + + /* + * Ulteriore controllo difensivo. + * + * Il layer ha già il filtro point_count, + * ma verifichiamo comunque che la feature + * sia realmente un cluster. + */ + const pointCount = + feature.properties?.point_count; + + if ( + pointCount === undefined || + pointCount === null + ) { + continue; + } + + const rawClusterId = + feature.properties?.cluster_id; + + if ( + rawClusterId === undefined || + rawClusterId === null + ) { + continue; + } + + const clusterId = + String(rawClusterId); + + /* + * La stessa feature può essere restituita + * più volte a causa delle tile Mapbox. + * Deduplichiamo tramite cluster_id. + */ + if (seen.has(clusterId)) { + continue; + } + + const coordinates = + feature.geometry?.coordinates; + + if ( + !Array.isArray(coordinates) || + coordinates.length < 2 + ) { + continue; + } + + const lng = + Number(coordinates[0]); + + const lat = + Number(coordinates[1]); + + if ( + !Number.isFinite(lng) || + !Number.isFinite(lat) + ) { + continue; + } + + seen.add(clusterId); + clusters.push(feature); + } + + return clusters; +} + + // =================================== + // CREA ELEMENTO HTML + // =================================== + + function createClusterElement(cluster) { + const count = + Number(cluster.properties?.point_count) || 0; + + const size = Math.min( + 72, + Math.round(30 + Math.sqrt(count) * 5), + ); + + const cls = + count > 200 + ? "cluster-xl" + : count > 50 + ? "cluster-lg" + : count > 10 + ? "cluster-md" + : "cluster-sm"; + + const el = + document.createElement("div"); + + el.className = + "activity-cluster-wrapper"; + + el.style.width = `${size}px`; + el.style.height = `${size}px`; + + el.innerHTML = ` +
+
+ ${count} +
+
+ `; + + return el; + } + + // =================================== + // CLICK CLUSTER + // =================================== + +function handleClusterClick( + clusterId, + coordinates, +) { + if (!mapboxMap) { + return; + } + + if ( + window.MapboxActivitySpiderfy + ?.handleClusterClick + ) { + window.MapboxActivitySpiderfy + .handleClusterClick( + clusterId, + coordinates, + ); + + return; + } + + const source = + mapboxMap.getSource( + SOURCE_ID, + ); + + if (!source) { + return; + } + + source.getClusterExpansionZoom( + Number(clusterId), + ( + error, + expansionZoom, + ) => { + if (error) { + console.error( + "[ActivityClusters] expansion zoom error", + error, + ); + + return; + } + + mapboxMap.easeTo({ + center: + coordinates, + + zoom: + expansionZoom, + + duration: + 600, + }); + }, + ); +} + + // =================================== + // CREA MARKER + // =================================== + +function createClusterMarker(cluster) { + if (!mapboxMap) { + return null; + } + + const rawClusterId = + cluster?.properties?.cluster_id; + + if ( + rawClusterId === undefined || + rawClusterId === null + ) { + return null; + } + + const clusterId = + String(rawClusterId); + + // Evita un secondo marker HTML + // per lo stesso cluster. + if ( + clusterMarkers.has(clusterId) + ) { + return clusterMarkers.get( + clusterId, + ); + } + + const coordinates = + cluster?.geometry?.coordinates; + + if ( + !Array.isArray(coordinates) || + coordinates.length < 2 || + !Number.isFinite( + Number(coordinates[0]), + ) || + !Number.isFinite( + Number(coordinates[1]), + ) + ) { + return null; + } + + const el = + createClusterElement(cluster); + + if (!el) { + return null; + } + + el.dataset.clusterId = + clusterId; + + // Evita che Mapbox inizi il pan + // quando si preme sul cluster. + el.addEventListener( + "pointerdown", + (event) => { + event.stopPropagation(); + }, + ); + + // Click sul cluster. + el.addEventListener( + "click", + (event) => { + event.preventDefault(); + event.stopPropagation(); + + handleClusterClick( + Number(clusterId), + coordinates, + ); + }, + ); + + const marker = + new mapboxgl.Marker({ + element: el, + anchor: "center", + }) + .setLngLat([ + Number(coordinates[0]), + Number(coordinates[1]), + ]) + .addTo(mapboxMap); + + marker.__clusterId = + clusterId; + + // La registrazione avviene qui. + clusterMarkers.set( + clusterId, + marker, + ); + + return marker; +} + +// =================================== + // UPDATE + // =================================== +function update() { + if ( + window.state?.mode !== + "activities" + ) { + clear(); + + return; + } + + if (!mapboxMap) { + return; + } + + if ( + !mapboxMap.isStyleLoaded?.() + ) { + return; + } + + // Non aggiornare cluster normali + // mentre Activity Spiderfy è aperto. + if ( + window.MapboxActivitySpiderfy + ?.isOpen?.() + ) { + return; + } + + // Recupera la source Activities. + const source = + mapboxMap.getSource( + SOURCE_ID, + ); + + if (!source) { + clear(); + return; + } + + // Crea il layer cluster se manca. + if ( + !mapboxMap.getLayer( + CLUSTER_LAYER_ID, + ) + ) { + createLayer(); + } + + // Se il layer non è ancora disponibile, + // elimina eventuali marker HTML obsoleti. + if ( + !mapboxMap.getLayer( + CLUSTER_LAYER_ID, + ) + ) { + clear(); + return; + } + + // Invalida eventuali aggiornamenti + // precedenti ancora in corso. + const token = + ++renderToken; + + // Recupera solo i cluster realmente + // renderizzati nella viewport. + const clusters = + getRenderedClusters(); + +console.log( + "[ActivityClusters] rendered", + { + clusters: + clusters.length, + + existingMarkers: + clusterMarkers.size, + + zoom: + mapboxMap.getZoom?.(), + + mode: + window.state?.mode, + + sourceFeatures: + mapboxMap + .querySourceFeatures?.( + SOURCE_ID, + ) + ?.length ?? null, + + circleOpacity: + mapboxMap + .getPaintProperty?.( + CLUSTER_LAYER_ID, + "circle-opacity", + ), + }, +); + + // Costruisce l'elenco dei cluster + // attualmente renderizzati. + const currentIds = + new Set( + clusters + .map( + (cluster) => + cluster + ?.properties + ?.cluster_id, + ) + .filter( + (clusterId) => + clusterId !== undefined && + clusterId !== null, + ) + .map( + (clusterId) => + String(clusterId), + ), + ); + + // Rimuove i cluster HTML che non sono + // più renderizzati al livello di zoom + // o nella viewport corrente. + for ( + const [clusterId, marker] + of clusterMarkers + ) { + if ( + !currentIds.has( + String(clusterId), + ) + ) { + marker.remove(); + + clusterMarkers.delete( + clusterId, + ); + } + } + + // Zero cluster è uno stato valido. + // La pulizia è già stata eseguita. + if (clusters.length === 0) { + return; + } + + // Crea oppure aggiorna i cluster correnti. + for (const cluster of clusters) { + // Se è partito un altro aggiornamento, + // interrompe quello precedente. + if (token !== renderToken) { + return; + } + + const rawClusterId = + cluster + ?.properties + ?.cluster_id; + + if ( + rawClusterId === undefined || + rawClusterId === null + ) { + continue; + } + + const clusterId = + String(rawClusterId); + + const coordinates = + cluster + ?.geometry + ?.coordinates; + + if ( + !Array.isArray(coordinates) || + coordinates.length < 2 + ) { + continue; + } + + const longitude = + Number(coordinates[0]); + + const latitude = + Number(coordinates[1]); + + if ( + !Number.isFinite(longitude) || + !Number.isFinite(latitude) + ) { + continue; + } + + // Aggiorna un cluster già esistente. + if ( + clusterMarkers.has( + clusterId, + ) + ) { + const marker = + clusterMarkers.get( + clusterId, + ); + + marker.setLngLat([ + longitude, + latitude, + ]); + + const element = + marker.getElement?.(); + + if (element) { + element.style.display = + ""; + } + + continue; + } + + // createClusterMarker registra già + // il marker dentro clusterMarkers. + createClusterMarker( + cluster, + ); + } +} + + // =================================== + // HIDE + // =================================== + + function hide() { + for ( + const marker of + clusterMarkers.values() + ) { + marker + .getElement() + .style.display = "none"; + } + } + + // =================================== + // SHOW + // =================================== + + function show() { + for ( + const marker of + clusterMarkers.values() + ) { + marker + .getElement() + .style.display = ""; + } + } + + // =================================== + // CLEAR + // =================================== + + function clear() { + renderToken++; + + for ( + const marker of + clusterMarkers.values() + ) { + marker.remove(); + } + + clusterMarkers.clear(); + } + + // =================================== + // SIZE + // =================================== + + function size() { + return clusterMarkers.size; + } + + // =================================== + // DEBUG + // =================================== + + function debug() { + console.log( + "[ActivityClusters] debug", + { + markers: + clusterMarkers.size, + + rendered: + getRenderedClusters().length, + + layer: + !!mapboxMap?.getLayer( + CLUSTER_LAYER_ID, + ), + + source: + !!mapboxMap?.getSource( + SOURCE_ID, + ), + }, + ); + } + + // =================================== + // API PUBBLICA + // =================================== + + window.MapboxActivityClusters = { + setMap, + + createLayer, + + update, + + hide, + + show, + + clear, + + size, + + debug, + + getRenderedClusters, + }; +})(); diff --git a/public/js/map/activities/activityMarkers.js b/public/js/map/activities/activityMarkers.js new file mode 100644 index 0000000..dcb5ae4 --- /dev/null +++ b/public/js/map/activities/activityMarkers.js @@ -0,0 +1,666 @@ +// public/js/map/activities/activityMarkers.js +// =================================== +// MAPBOX ACTIVITY MARKERS +// =================================== + +(() => { + "use strict"; + + let mapboxMap = null; + + const markers = new Map(); + + const SOURCE_ID = "activities"; + + // =================================== + // SET MAP + // =================================== + + function setMap(map) { + mapboxMap = map; + + console.log("[ActivityMarkers] setMap"); + } + + // =================================== + // CREA ELEMENTO ATTIVITÀ + // =================================== + +function getActivityIcon(activityName) { + const sport = String( + activityName || "", + ) + .toLowerCase() + .trim(); + + if ( + sport.includes("cycling") || + sport.includes("cycle") || + sport.includes("ride") || + sport.includes("bike") || + sport.includes("bici") || + sport.includes("cicl") + ) { + return "🚴"; + } + + if ( + sport.includes("running") || + sport.includes("run") || + sport.includes("corsa") + ) { + return "🏃"; + } + + if ( + sport.includes("walking") || + sport.includes("walk") || + sport.includes("cammin") + ) { + return "🚶"; + } + + if ( + sport.includes("hiking") || + sport.includes("hike") || + sport.includes("trek") + ) { + return "🥾"; + } + + if ( + sport.includes("swimming") || + sport.includes("swim") || + sport.includes("nuoto") + ) { + return "🏊"; + } + + return "📍"; +} + +function createActivityElement(feature) { + const el = + document.createElement("div"); + + el.className = + "activity-marker"; + + const inner = + document.createElement("div"); + + inner.className = + "activity-marker-inner"; + + const icon = + document.createElement("span"); + + icon.className = + "activity-marker-icon"; + + icon.textContent = + getActivityIcon( + feature?.properties?.sport, + ); + + inner.appendChild(icon); + el.appendChild(inner); + + return el; +} + + // =================================== + // CLICK ATTIVITÀ + // =================================== + +function handleActivityClick(feature) { + if (!feature) { + return; + } + + const rawId = + feature.properties?.id; + + if ( + rawId === undefined || + rawId === null || + String(rawId).trim() === "" + ) { + console.warn( + "[ActivityMarkers] feature senza id", + feature, + ); + + return; + } + + const id = + String(rawId); + + const items = + typeof window.getLocalItems === + "function" + ? window.getLocalItems() + : []; + + /* + * Mantiene soltanto gli elementi + * riconosciuti come attività. + */ + const activityItems = + items.filter((item) => { + if (!item) { + return false; + } + + const format = String( + item.format || + item.file_extension || + item.mime_type || + item.file_name || + item.name || + "", + ).toLowerCase(); + + return ( + format.includes("fit") || + format.includes("gpx") || + format.includes("tcx") || + item.distance_meters != null || + item.duration_millis != null || + item.track_preview != null + ); + }); + + /* + * Ricostruisce la stessa chiave utilizzata + * da getActivityKey() in activitiesLayer.js. + */ + const itemIndex = + activityItems.findIndex( + (item, index) => { + const itemKey = + String( + item.id || + item.path || + item.file_name || + item.name || + index, + ); + + return itemKey === id; + }, + ); + + if (itemIndex < 0) { + console.warn( + "[ActivityMarkers] attività non trovata", + { + id, + available: + activityItems.length, + }, + ); + + return; + } + + const item = + activityItems[itemIndex]; + + if ( + typeof window.openActivityModal !== + "function" + ) { + console.warn( + "[ActivityMarkers] openActivityModal non disponibile", + ); + + return; + } + + console.log( + "[ActivityMarkers] apertura attività", + { + id, + itemIndex, + item, + }, + ); + + window.openActivityModal( + item, + activityItems, + itemIndex, + ); +} + + // =================================== + // CREA MARKER + // =================================== + + function createMarker(feature) { + if (!mapboxMap) { + return null; + } + + if (!feature) { + return null; + } + + const rawId = + feature.properties?.id; + + if ( + rawId === undefined || + rawId === null || + String(rawId).trim() === "" + ) { + console.warn( + "[ActivityMarkers] feature senza id", + feature, + ); + + return null; + } + + const id = + String(rawId); + + // Evita marker duplicati. + if (markers.has(id)) { + return markers.get(id); + } + + const coordinates = + feature.geometry?.coordinates; + + if ( + !Array.isArray(coordinates) || + coordinates.length < 2 || + !Number.isFinite( + Number(coordinates[0]), + ) || + !Number.isFinite( + Number(coordinates[1]), + ) + ) { + console.warn( + "[ActivityMarkers] coordinate non valide", + feature, + ); + + return null; + } + + const el = + createActivityElement( + feature, + ); + + if (!el) { + console.warn( + "[ActivityMarkers] elemento marker non creato", + { + id, + feature, + }, + ); + + return null; + } + + // ================================= + // CLICK + // ================================= + + el.addEventListener( + "click", + (event) => { + event.preventDefault(); + event.stopPropagation(); + + handleActivityClick( + feature, + ); + }, + ); + + /* + * Evita che pointerdown o touchstart + * avviino il trascinamento della mappa + * quando l'utente vuole aprire il marker. + */ + el.addEventListener( + "pointerdown", + (event) => { + event.stopPropagation(); + }, + ); + + el.addEventListener( + "touchstart", + (event) => { + event.stopPropagation(); + }, + { + passive: true, + }, + ); + + // ================================= + // MARKER MAPBOX + // ================================= + + const marker = + new mapboxgl.Marker({ + element: el, + anchor: "center", + }) + .setLngLat([ + Number(coordinates[0]), + Number(coordinates[1]), + ]) + .addTo(mapboxMap); + + marker.__activityId = + id; + + marker.__originalLngLat = [ + Number(coordinates[0]), + Number(coordinates[1]), + ]; + + markers.set( + id, + marker, + ); + + return marker; +} + // =================================== + // ATTIVITÀ VISIBILI + // =================================== + + function getVisibleIds() { + if (!mapboxMap) { + return new Set(); + } + + if (!mapboxMap.getSource(SOURCE_ID)) { + return new Set(); + } + + const sourcePoints = + mapboxMap.querySourceFeatures( + SOURCE_ID, + { + filter: [ + "!", + ["has", "point_count"], + ], + }, + ); + + +console.log( + "[ActivityMarkers IDs]", + sourcePoints.map( + f => String(f.properties?.id) + ) +); + +console.log( + "[ActivityMarkers UNIQUE]", + new Set( + sourcePoints.map( + f => String(f.properties?.id) + ) + ).size +); + + +return new Set( + sourcePoints + .map( + (feature) => + feature.properties?.id, + ) + .filter( + (id) => + id !== undefined && + id !== null && + String(id).trim() !== "", + ) + .map((id) => String(id)), +); + + } + + // =================================== + // UPDATE + // =================================== + + function update(features = []) { + if (!mapboxMap) { + return; + } + + + if (!mapboxMap.getSource(SOURCE_ID)) { + return; + } + + const visibleIds = + getVisibleIds(); + + console.log( + "[ActivityMarkers] visibility", + { + features: features.length, + visible: visibleIds.size, + }, + ); + + // ================================= + // RIMUOVI MARKER CLUSTERIZZATI + // ================================= + + for ( + const [ + id, + marker, + ] of markers + ) { + if (!visibleIds.has(id)) { + marker.remove(); + + markers.delete(id); + } + } + + // ================================= + // CREA / AGGIORNA + // ================================= + + for (const feature of features) { + if (!feature) { + continue; + } + + const id = String( + feature.properties?.id ?? "", + ); + + if (!id) { + continue; + } + + /* + * Se l'attività è dentro un cluster + * non mostriamo il marker singolo. + */ + + if (!visibleIds.has(id)) { + continue; + } + + const coordinates = + feature.geometry?.coordinates; + + if ( + !coordinates || + coordinates.length < 2 + ) { + continue; + } + + // -------------------------------- + // MARKER ESISTENTE + // -------------------------------- + + if (markers.has(id)) { + const marker = + markers.get(id); + + marker.setLngLat( + coordinates, + ); + + marker.__originalLngLat = + coordinates; + + marker + .getElement() + .style.display = ""; + + continue; + } + + // -------------------------------- + // CREA MARKER + // -------------------------------- + + createMarker(feature); + } + + // ================================= + // RIMOZIONE FINALE + // ================================= + + for ( + const [ + id, + marker, + ] of markers + ) { + if (!visibleIds.has(id)) { + marker.remove(); + + markers.delete(id); + } + } + } + + // =================================== + // GET MARKER + // =================================== + + function get(id) { + return markers.get( + String(id), + ); + } + + // =================================== + // GET ALL + // =================================== + + function getAll() { + return markers; + } + + // =================================== + // REMOVE + // =================================== + + function remove(id) { + const key = String(id); + + const marker = + markers.get(key); + + if (!marker) { + return; + } + + marker.remove(); + + markers.delete(key); + } + + // =================================== + // CLEAR + // =================================== + + function clear() { + for ( + const marker of markers.values() + ) { + marker.remove(); + } + + markers.clear(); + } + + // =================================== + // SIZE + // =================================== + + function size() { + return markers.size; + } + + // =================================== + // DEBUG + // =================================== + + function debug() { + console.log( + "[ActivityMarkers] debug", + { + markers: + markers.size, + + ids: + [...markers.keys()], + }, + ); + } + + // =================================== + // API PUBBLICA + // =================================== + + window.MapboxActivityMarkers = { + setMap, + + update, + + createMarker, + + getVisibleIds, + + get, + + getAll, + + remove, + + clear, + + size, + + debug, + }; +})(); diff --git a/public/js/map/activities/activitySource.js b/public/js/map/activities/activitySource.js new file mode 100644 index 0000000..8d8f7ff --- /dev/null +++ b/public/js/map/activities/activitySource.js @@ -0,0 +1,240 @@ +// public/js/map/activities/activitySource.js +// =================================== +// ACTIVITY SOURCE +// =================================== + +(() => { + "use strict"; + + const SOURCE_ID = "activities"; + const CLUSTER_RADIUS = 80; + + // =============================== + // CREATE SOURCE + // =============================== + + function createActivitySource( + map, + features = [], + ) { + if (!map) { + return null; + } + + if (!map.isStyleLoaded()) { + console.log( + "[ActivitySource] create rimandato: style non pronto", + ); + + return null; + } + + const existingSource = + map.getSource(SOURCE_ID); + + if (existingSource) { + existingSource.setData({ + type: "FeatureCollection", + features, + }); + + return existingSource; + } + + const data = { + type: "FeatureCollection", + features, + }; + + console.log( + "[ActivitySource] creo source activities", + { + features: features.length, + }, + ); + + map.addSource(SOURCE_ID, { + type: "geojson", + data, + + cluster: true, + clusterRadius: CLUSTER_RADIUS, + }); + + console.log( + "[ActivitySource] source activities creata", + { + source: + !!map.getSource(SOURCE_ID), + }, + ); + + return map.getSource(SOURCE_ID); + } + + // =============================== + // UPDATE SOURCE + // =============================== + + function updateActivitySource( + map, + features = [], + ) { + if (!map) { + return null; + } + +console.log( +"[ActivitySource.update]", +{ +features: features.length, +styleLoaded: map.isStyleLoaded(), +sourceExists: !!map.getSource("activities"), +} +) + + if (!map.isStyleLoaded()) { + console.log( + "[ActivitySource] update rimandato: style non pronto", + ); + + return null; + } + + const data = { + type: "FeatureCollection", + features, + }; + + const source = + map.getSource(SOURCE_ID); + + // -------------------------------- + // SOURCE ESISTENTE + // -------------------------------- + + if (source) { + source.setData(data); + + console.log( + "[ActivitySource] source aggiornata", + { + features: features.length, + }, + ); + + return source; + } + + // -------------------------------- + // SOURCE NON ESISTENTE + // -------------------------------- + + return createActivitySource( + map, + features, + ); + } + + // =============================== + // GET SOURCE + // =============================== + + function getActivitySource(map) { + if (!map) { + return null; + } + + return ( + map.getSource(SOURCE_ID) || + null + ); + } + + // =============================== + // GET FEATURES + // =============================== + + function getActivitySourceFeatures(map) { + const source = + getActivitySource(map); + + if (!source) { + return []; + } + + return source._data?.features || []; + } + + // =============================== + // GET CLUSTERS + // =============================== + + function getActivitySourceClusters(map) { + if (!map) { + return []; + } + + if (!map.getSource(SOURCE_ID)) { + return []; + } + + return map.querySourceFeatures( + SOURCE_ID, + { + filter: [ + "has", + "point_count", + ], + }, + ); + } + + // =============================== + // GET POINTS + // =============================== + + function getActivitySourcePoints(map) { + if (!map) { + return []; + } + + if (!map.getSource(SOURCE_ID)) { + return []; + } + + return map.querySourceFeatures( + SOURCE_ID, + { + filter: [ + "!", + ["has", "point_count"], + ], + }, + ); + } + + // =============================== + // PUBLIC API + // =============================== + + window.ActivitySource = { + create: + createActivitySource, + + update: + updateActivitySource, + + get: + getActivitySource, + + getFeatures: + getActivitySourceFeatures, + + getClusters: + getActivitySourceClusters, + + getPoints: + getActivitySourcePoints, + }; +})(); diff --git a/public/js/map/activities/activitySpiderfy.js b/public/js/map/activities/activitySpiderfy.js new file mode 100644 index 0000000..b468c12 --- /dev/null +++ b/public/js/map/activities/activitySpiderfy.js @@ -0,0 +1,964 @@ +// public/js/map/activities/activitySpiderfy.js +// MAPBOX ACTIVITY SPIDERFY + +(() => { + "use strict"; + + let mapboxMap = null; + let eventsMap = null; + + const spiderMarkers = []; + + let spiderfyOpen = false; + + const SOURCE_ID = + "activities"; + + const CLUSTER_LAYER_ID = + "activities-clusters"; + + // =================================== + // SET MAP + // =================================== + + function setMap(map) { + if (!map) { + return; + } + + if (eventsMap === map) { + mapboxMap = map; + return; + } + + detachEvents(); + + mapboxMap = map; + eventsMap = map; + + eventsMap.on( + "zoomstart", + handleZoomStart, + ); + + eventsMap.on( + "moveend", + handleMoveEnd, + ); + + eventsMap.on( + "click", + handleMapClick, + ); + + console.log( + "[ActivitySpiderfy] setMap", + ); + } + + // =================================== + // REMOVE EVENTS + // =================================== + + function detachEvents() { + if (!eventsMap) { + return; + } + + eventsMap.off( + "zoomstart", + handleZoomStart, + ); + + eventsMap.off( + "moveend", + handleMoveEnd, + ); + + eventsMap.off( + "click", + handleMapClick, + ); + + eventsMap = null; + } + + // =================================== + // MAP EVENTS + // =================================== + + function handleZoomStart() { + if (!spiderfyOpen) { + return; + } + + clear(); + } + + function handleMoveEnd() { + if (spiderfyOpen) { + return; + } + + window.MapboxActivityClusters + ?.update?.(); + + const activities = + getAllActivities(); + + window.MapboxActivityMarkers + ?.update?.( + activities, + ); + } + + function handleMapClick(event) { + if (!spiderfyOpen) { + return; + } + + if ( + !mapboxMap?.getLayer?.( + CLUSTER_LAYER_ID, + ) + ) { + clear(); + return; + } + + const hits = + mapboxMap.queryRenderedFeatures( + event.point, + { + layers: [ + CLUSTER_LAYER_ID, + ], + }, + ); + + if (hits.length > 0) { + return; + } + + clear(); + } + + // =================================== + // GET ACTIVITIES + // =================================== + + function getAllActivities() { + const controllerActivities = + window.MapboxMarkers + ?.getActivities?.() || []; + + if (controllerActivities.length) { + return controllerActivities; + } + + if (!mapboxMap) { + return []; + } + + if ( + !mapboxMap.getSource( + SOURCE_ID, + ) + ) { + return []; + } + + return mapboxMap.querySourceFeatures( + SOURCE_ID, + { + filter: [ + "!", + ["has", "point_count"], + ], + }, + ); + } + + // =================================== + // DISTANCE + // =================================== + + function distanceMeters(a, b) { + if ( + !Array.isArray(a) || + !Array.isArray(b) || + a.length < 2 || + b.length < 2 + ) { + return Infinity; + } + + const R = 6371000; + + const lat1 = + Number(a[1]) * + Math.PI / + 180; + + const lat2 = + Number(b[1]) * + Math.PI / + 180; + + const dLat = + lat2 - lat1; + + const dLng = + ( + Number(b[0]) - + Number(a[0]) + ) * + Math.PI / + 180; + + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1) * + Math.cos(lat2) * + Math.sin(dLng / 2) ** 2; + + return ( + 2 * + R * + Math.atan2( + Math.sqrt(h), + Math.sqrt(1 - h), + ) + ); + } + + // =================================== + // NEARBY ACTIVITIES + // =================================== + + function getNearbyActivities( + feature, + maxDistance = 3, + ) { + const center = + feature?.geometry?.coordinates; + + if ( + !Array.isArray(center) || + center.length < 2 + ) { + return []; + } + + return getAllActivities().filter( + (other) => { + const coordinates = + other?.geometry?.coordinates; + + return ( + distanceMeters( + center, + coordinates, + ) <= maxDistance + ); + }, + ); + } + + // =================================== + // ACTIVITY ITEM + // =================================== + + function isActivityItem(item) { + if (!item) { + return false; + } + + const format = String( + item.format || + item.file_extension || + item.mime_type || + item.file_name || + item.name || + "", + ).toLowerCase(); + + return ( + format.includes("fit") || + format.includes("gpx") || + format.includes("tcx") || + item.distance_meters != null || + item.duration_millis != null || + item.track_preview != null + ); + } + + function getActivityKey( + item, + index, + ) { + return String( + item?.id || + item?.path || + item?.file_name || + item?.name || + index, + ); + } + + function openActivityFeature( + feature, + ) { + const id = + String( + feature?.properties?.id ?? + "", + ); + + if (!id) { + return; + } + + const allItems = + typeof window.getLocalItems === + "function" + ? window.getLocalItems() + : []; + + const activityItems = + allItems.filter( + isActivityItem, + ); + + const itemIndex = + activityItems.findIndex( + (item, index) => + getActivityKey( + item, + index, + ) === id, + ); + + if (itemIndex < 0) { + console.warn( + "[ActivitySpiderfy] attività non trovata", + { + id, + available: + activityItems.length, + }, + ); + + return; + } + + if ( + typeof window.openActivityModal !== + "function" + ) { + return; + } + + window.openActivityModal( + activityItems[itemIndex], + activityItems, + itemIndex, + ); + } + + // =================================== + // ICON + // =================================== + + function getActivityIcon( + activityName, + ) { + const sport = String( + activityName || "", + ) + .toLowerCase() + .trim(); + + if ( + sport.includes("cycling") || + sport.includes("cycle") || + sport.includes("ride") || + sport.includes("bike") || + sport.includes("bici") || + sport.includes("cicl") + ) { + return "🚴"; + } + + if ( + sport.includes("running") || + sport.includes("run") || + sport.includes("corsa") + ) { + return "🏃"; + } + + if ( + sport.includes("walking") || + sport.includes("walk") || + sport.includes("cammin") + ) { + return "🚶"; + } + + if ( + sport.includes("hiking") || + sport.includes("hike") || + sport.includes("trek") + ) { + return "🥾"; + } + + if ( + sport.includes("swimming") || + sport.includes("swim") || + sport.includes("nuoto") + ) { + return "🏊"; + } + + return "📍"; + } + + // =================================== + // SPIDER ELEMENT + // =================================== + + function createSpiderElement( + feature, + ) { + const el = + document.createElement("div"); + + el.className = + "activity-marker activity-spider-marker"; + + const inner = + document.createElement("div"); + + inner.className = + "activity-marker-inner"; + + const icon = + document.createElement("span"); + + icon.className = + "activity-marker-icon"; + + icon.textContent = + getActivityIcon( + feature?.properties?.sport, + ); + + inner.appendChild(icon); + el.appendChild(inner); + + return el; + } + + // =================================== + // SPIDER MARKER + // =================================== + + function createSpiderMarker( + feature, + id, + ) { + if (!mapboxMap) { + return null; + } + + const coordinates = + feature?.geometry?.coordinates; + + if ( + !Array.isArray(coordinates) || + coordinates.length < 2 + ) { + return null; + } + + const el = + createSpiderElement( + feature, + ); + + el.addEventListener( + "pointerdown", + (event) => { + event.stopPropagation(); + }, + ); + + el.addEventListener( + "click", + (event) => { + event.preventDefault(); + event.stopPropagation(); + + openActivityFeature( + feature, + ); + + clear(); + }, + ); + + const marker = + new mapboxgl.Marker({ + element: el, + anchor: "center", + }) + .setLngLat(coordinates) + .addTo(mapboxMap); + + marker.__originalLngLat = + coordinates; + + marker.__activityId = + id; + + marker.__spider = + true; + + return marker; + } + + // =================================== + // HIDE / SHOW CLUSTERS + // =================================== + + function hideClusterMarkers() { + window.MapboxActivityClusters + ?.hide?.(); + } + + function showClusterMarkers() { + window.MapboxActivityClusters + ?.show?.(); + } + + // =================================== + // CLEAR + // =================================== + + function clear() { + for ( + const marker + of spiderMarkers + ) { + marker.remove(); + } + + spiderMarkers.length = 0; + spiderfyOpen = false; + + if (mapboxMap) { + mapboxMap.triggerRepaint(); + } + + showClusterMarkers(); + + window.MapboxActivityClusters + ?.update?.(); + + window.MapboxActivityMarkers + ?.update?.( + getAllActivities(), + ); + + console.log( + "[ActivitySpiderfy] chiuso", + ); + } + + // =================================== + // SPIDERFY + // =================================== + + function spiderfyActivities( + features, + ) { + if ( + !mapboxMap || + !Array.isArray(features) || + features.length === 0 + ) { + return; + } + + clear(); + + const center = + features[0] + ?.geometry + ?.coordinates; + + if ( + !Array.isArray(center) || + center.length < 2 + ) { + return; + } + + const count = + features.length; + + const radiusPx = + Math.min( + 96, + Math.max( + 30, + 28 + count * 6, + ), + ); + + const centerPoint = + mapboxMap.project( + center, + ); + + hideClusterMarkers(); + + features.forEach( + (feature, index) => { + const rawId = + feature?.properties?.id; + + if ( + rawId === undefined || + rawId === null + ) { + return; + } + + const id = + String(rawId); + + window.MapboxActivityMarkers + ?.remove?.( + id, + ); + + const marker = + createSpiderMarker( + feature, + id, + ); + + if (!marker) { + return; + } + + const angle = + ( + Math.PI * + 2 * + index + ) / + count - + Math.PI / 2; + + const point = { + x: + centerPoint.x + + Math.cos(angle) * + radiusPx, + + y: + centerPoint.y + + Math.sin(angle) * + radiusPx, + }; + + const lngLat = + mapboxMap.unproject( + point, + ); + + marker.setLngLat( + lngLat, + ); + + spiderMarkers.push( + marker, + ); + }, + ); + + spiderfyOpen = + spiderMarkers.length > 0; + + console.log( + "[ActivitySpiderfy] aperto", + { + activities: + spiderMarkers.length, + + radiusPx, + }, + ); + } + + // =================================== + // SMART ZOOM + // =================================== + + function smartZoomCluster( + clusterId, + center, + ) { + if (!mapboxMap) { + return; + } + + const source = + mapboxMap.getSource( + SOURCE_ID, + ); + + if (!source) { + return; + } + + const MAX_STEPS = 5; + + let steps = 0; + + function zoomStep() { + if (steps >= MAX_STEPS) { + return; + } + + steps++; + + source.getClusterExpansionZoom( + Number(clusterId), + ( + error, + expansionZoom, + ) => { + if (error) { + console.error( + "[ActivitySpiderfy] expansion zoom error", + error, + ); + + return; + } + + const currentZoom = + mapboxMap.getZoom(); + + const targetZoom = + Math.max( + expansionZoom, + currentZoom + 1, + ); + + mapboxMap.once( + "moveend", + () => { + const clusters = + mapboxMap + .querySourceFeatures( + SOURCE_ID, + { + filter: [ + "has", + "point_count", + ], + }, + ); + + const sameCluster = + clusters.some( + (feature) => + Number( + feature + ?.properties + ?.cluster_id, + ) === + Number(clusterId), + ); + + if (sameCluster) { + zoomStep(); + } else { + window + .MapboxActivityClusters + ?.update?.(); + + window + .MapboxActivityMarkers + ?.update?.( + getAllActivities(), + ); + } + }, + ); + + mapboxMap.easeTo({ + center, + zoom: targetZoom, + duration: 700, + }); + }, + ); + } + + zoomStep(); + } + + // =================================== + // CLUSTER CLICK + // =================================== + + function handleClusterClick( + clusterId, + center, + ) { + if (!mapboxMap) { + return; + } + + const source = + mapboxMap.getSource( + SOURCE_ID, + ); + + if (!source) { + return; + } + + source.getClusterLeaves( + Number(clusterId), + 100, + 0, + (error, leaves) => { + if (error) { + console.error( + "[ActivitySpiderfy] cluster leaves error", + error, + ); + + return; + } + + if ( + !Array.isArray(leaves) || + leaves.length === 0 + ) { + return; + } + + const firstCoordinates = + leaves[0] + ?.geometry + ?.coordinates; + + if ( + !Array.isArray( + firstCoordinates, + ) + ) { + return; + } + + const radiusMeters = 30; + + const allNearby = + leaves.every( + (feature) => + distanceMeters( + firstCoordinates, + feature + ?.geometry + ?.coordinates, + ) <= radiusMeters, + ); + + console.log( + "[ActivitySpiderfy] cluster click", + { + clusterId, + leaves: + leaves.length, + + spider: + allNearby, + }, + ); + + if (allNearby) { + spiderfyActivities( + leaves, + ); + + return; + } + + smartZoomCluster( + clusterId, + center, + ); + }, + ); + } + + // =================================== + // STATE + // =================================== + + function isOpen() { + return spiderfyOpen; + } + + function size() { + return spiderMarkers.length; + } + + function debug() { + const result = { + open: + spiderfyOpen, + + markers: + spiderMarkers.length, + + map: + !!mapboxMap, + }; + + console.log( + "[ActivitySpiderfy] debug", + result, + ); + + return result; + } + + // =================================== + // PUBLIC API + // =================================== + + window.MapboxActivitySpiderfy = { + setMap, + clear, + + spiderfyActivities, + getNearbyActivities, + + handleClusterClick, + smartZoomCluster, + + isOpen, + size, + debug, + }; +})(); diff --git a/public/js/map/activitiesLayer.js b/public/js/map/activitiesLayer.js new file mode 100644 index 0000000..5bc3d67 --- /dev/null +++ b/public/js/map/activitiesLayer.js @@ -0,0 +1,1224 @@ +// public/js/map/activitiesLayer.js +// =============================== +// LAYER MAPPA ATTIVITÀ +// =============================== + +(function () { + const ACTIVITY_ROUTE_COLORS = [ + "#e6194b", + "#3cb44b", + "#4363d8", + "#f58231", + "#911eb4", + "#46f0f0", + "#f032e6", + "#bcf60c", + "#008080", + "#9a6324", + "#800000", + "#000075", + "#ff6b00", + "#00a8e8", + "#8d6e63", + "#2e7d32", + ]; + + const MAX_GLOBAL_ACTIVITY_ROUTES = 80; + + let activityRoutesLayer = null; + let selectedActivityLayer = null; + let selectedActivityEndMarker = null; + let showAllActivityRoutes = false; + let cachedRouteData = []; + let lastRenderToken = 0; + let activityStartMarkers = []; + + // =============================== + // CLUSTER ATTIVITÀ + // =============================== + + + window.createActivityClusterIcon = function (cluster) { + const count = cluster.getChildCount(); + + const cls = + count > 200 + ? "cluster-xl" + : count > 50 + ? "cluster-lg" + : count > 10 + ? "cluster-md" + : "cluster-sm"; + + return L.divIcon({ + html: ` +
+ ${count} +
+ `, + + className: "activity-leaflet-cluster", + + iconSize: L.point(48, 48), + + iconAnchor: L.point(24, 24), + }); + }; + + // =============================== + // PULIZIA LAYER ATTIVITÀ + // =============================== + + window.clearActivityMapLayers = function () { + clearActivityStartMarkers(); + + if (activityRoutesLayer) { + activityRoutesLayer.clearLayers(); + } + + if (selectedActivityLayer) { + selectedActivityLayer.remove(); + selectedActivityLayer = null; + } + + if (selectedActivityEndMarker) { + selectedActivityEndMarker.remove(); + selectedActivityEndMarker = null; + } + }; + + // =============================== + // PULIZIA MARKERS + // =============================== + + function clearActivityStartMarkers() { + const markersToRemove = [...activityStartMarkers]; + + activityStartMarkers.length = 0; + + for (const marker of markersToRemove) { + try { + window.globalMarkers?.removeLayer?.(marker); + + /* + * Rimuove direttamente il marker + * dalla mappa Leaflet, se presente. + */ + if (window.globalMap?.hasLayer?.(marker)) { + window.globalMap.removeLayer(marker); + } + + marker.remove?.(); + } catch (error) { + console.warn( + "[activitiesLayer] errore rimozione marker Leaflet", + error, + ); + } + } + } + + // =============================== + // TOGGLE TRACCE + // =============================== + function ensureActivityRoutesToggle() { + let btn = document.getElementById("toggleActivityRoutesBtn"); + + if (!btn) { + btn = document.createElement("button"); + + btn.id = "toggleActivityRoutesBtn"; + + btn.type = "button"; + + btn.className = "map-toggle-routes"; + + btn.addEventListener("click", async (event) => { + event.preventDefault(); + event.stopPropagation(); + + showAllActivityRoutes = !showAllActivityRoutes; + + updateActivityRoutesToggle(); + + const isMapbox3D = !!window.MapTerrain3D?.enabled?.(); + + if (isMapbox3D) { + updateMapboxActivityRoutes(); + } else { + await renderAllActivityRoutes(); + } + }); + } + + moveActivityRoutesToggle(btn); + + updateActivityRoutesToggle(); + + return btn; + } + + function moveActivityRoutesToggle(btn) { + if (!btn) { + return; + } + + const isMapbox3D = !!window.MapTerrain3D?.enabled?.(); + + const targetContainer = isMapbox3D + ? document.getElementById("terrainMap") + : document.getElementById("globalMap"); + + if (!targetContainer) { + return; + } + + if (btn.parentElement !== targetContainer) { + targetContainer.appendChild(btn); + } + } + + function updateActivityRoutesToggle() { + const btn = document.getElementById("toggleActivityRoutesBtn"); + + if (!btn) { + return; + } + + btn.classList.toggle("active", showAllActivityRoutes); + + btn.textContent = showAllActivityRoutes + ? "Nascondi tracce" + : "Mostra tracce"; + + btn.setAttribute("aria-pressed", showAllActivityRoutes ? "true" : "false"); + + btn.setAttribute( + "aria-label", + showAllActivityRoutes + ? "Nascondi tutte le tracce" + : "Mostra tutte le tracce", + ); + + btn.title = showAllActivityRoutes + ? "Nascondi tutte le tracce" + : "Mostra tutte le tracce"; + } + +window.hideActivityRoutesToggle = + function (resetState = false) { + const btn = + document.getElementById( + "toggleActivityRoutesBtn", + ); + + if (btn) { + btn.style.display = + "none"; + + btn.style.visibility = + "hidden"; + + btn.style.pointerEvents = + "none"; + } + + if (!resetState) { + return; + } + + showAllActivityRoutes = + false; + + updateActivityRoutesToggle(); + + if ( + window.MapTerrain3D + ?.enabled?.() + ) { + window.MapboxMarkers + ?.setRoutes?.([]); + } + + activityRoutesLayer + ?.clearLayers?.(); + }; + +function showActivityRoutesToggle() { + const btn = + ensureActivityRoutesToggle(); + + if (!btn) { + return; + } + + moveActivityRoutesToggle( + btn, + ); + + btn.style.display = + "inline-flex"; + + btn.style.visibility = + "visible"; + + btn.style.pointerEvents = + "auto"; + + updateActivityRoutesToggle(); +} + +window.showActivityRoutesToggle = + showActivityRoutesToggle; + + // =============================== + // ICONA MARKER ATTIVITÀ + // =============================== + function createActivityStartIcon(item) { + const sport = String(item?.sport || "") + .toLowerCase() + .trim(); + + let emoji = "●"; + let cls = "activity-marker-generic"; + + if (sport.includes("run") || sport.includes("corsa")) { + emoji = "🏃"; + cls = "activity-marker-run"; + } else if ( + sport.includes("bike") || + sport.includes("bici") || + sport.includes("cycle") || + sport.includes("cycling") || + sport.includes("ride") || + sport.includes("cicl") + ) { + emoji = "🚴"; + cls = "activity-marker-bike"; + } else if (sport.includes("walk") || sport.includes("cammin")) { + emoji = "🚶"; + cls = "activity-marker-walk"; + } else if ( + sport.includes("hike") || + sport.includes("hiking") || + sport.includes("trek") + ) { + emoji = "🥾"; + cls = "activity-marker-hiking"; + } else if (sport.includes("swim") || sport.includes("nuoto")) { + emoji = "🏊"; + cls = "activity-marker-swimming"; + } + + return L.divIcon({ + html: ` +
+ + ${emoji} + +
+ `, + + className: "activity-leaflet-marker", + + iconSize: L.point(42, 42), + + iconAnchor: L.point(21, 21), + }); + } + + // =============================== + + // ============================================= + // CACHE ATTIVITÀ DELLA SOLA AREA VISIBILE + // ============================================= + let mapActivityItems = []; + let mapActivityBoundsKey = null; + let mapActivityRequestedBoundsKey = null; + let mapActivityLoadPromise = null; + let mapActivityLoadGeneration = 0; + + function getActivityMapBounds() { + const bounds = + window.MapEngine?.getBounds?.() || + window.globalMap?.getBounds?.(); + + if (!bounds) return null; + + const west = bounds.getWest(); + const east = bounds.getEast(); + const south = bounds.getSouth(); + const north = bounds.getNorth(); + + if (![west, east, south, north].every(Number.isFinite)) { + return null; + } + + return { + west, east, south, north, + key: [west, south, east, north] + .map(v => v.toFixed(5)) + .join(","), + }; + } + + async function loadActivityMapItems() { + const bounds = getActivityMapBounds(); + if (!bounds) return false; + + if (mapActivityBoundsKey === bounds.key) { + return true; + } + + if ( + mapActivityLoadPromise && + mapActivityRequestedBoundsKey === bounds.key + ) { + await mapActivityLoadPromise; + const current = getActivityMapBounds(); + return !!current && current.key === mapActivityBoundsKey; + } + + const authToken = localStorage.getItem("token"); + if (!authToken) { + console.warn("[activitiesLayer] token non disponibile"); + return false; + } + + const generation = ++mapActivityLoadGeneration; + mapActivityRequestedBoundsKey = bounds.key; + + const params = new URLSearchParams({ + west: String(bounds.west), + east: String(bounds.east), + south: String(bounds.south), + north: String(bounds.north), + limit: "1500", + }); + + if (localStorage.getItem("showDeleted") === "1") { + params.set("showDeleted", "1"); + } + + const promise = (async () => { + try { + const response = await fetch( + "/activities/map?" + params.toString(), + { + headers: { + Authorization: "Bearer " + authToken, + }, + } + ); + + if (!response.ok) { + throw new Error("HTTP " + response.status); + } + + const data = await response.json(); + + if (generation !== mapActivityLoadGeneration) { + return false; + } + + if (!Array.isArray(data.items)) { + throw new Error("Risposta /activities/map senza array items"); + } + + const current = getActivityMapBounds(); + if (!current || current.key !== bounds.key) { + return false; + } + + mapActivityItems = data.items; + mapActivityBoundsKey = bounds.key; + + console.log("[activitiesLayer] attività per area caricate", { + count: mapActivityItems.length, + limit: data.limit, + truncated: data.truncated, + bounds: bounds.key, + }); + + if (data.truncated) { + console.warn( + "[activitiesLayer] limite attività raggiunto; " + + "zoomare o spostare la mappa per restringere l'area" + ); + } + + return true; + } catch (error) { + console.error( + "[activitiesLayer] errore caricamento attività per area:", + error + ); + return false; + } finally { + if (generation === mapActivityLoadGeneration) { + mapActivityLoadPromise = null; + mapActivityRequestedBoundsKey = null; + } + } + })(); + + mapActivityLoadPromise = promise; + await promise; + + const current = getActivityMapBounds(); + return !!current && current.key === mapActivityBoundsKey; + } + +// RENDER ATTIVITÀ + // =============================== + +window.renderActivityMapLayer = + async function () { + if ( + window.state?.mode !== + "activities" + ) { + return; + } + + const renderMode = + window.state.mode; + + const isMapbox3D = + !!window.MapTerrain3D + ?.enabled?.(); + + if ( + !isMapbox3D && + ( + !window.globalMap || + !window.globalMarkers + ) + ) { + return; + } + + const token = + ++lastRenderToken; + + const areaLoaded = await loadActivityMapItems(); + + if ( + !areaLoaded || + token !== lastRenderToken || + window.state?.mode !== renderMode + ) { + return; + } + + clearActivityStartMarkers(); + showActivityRoutesToggle(); + + /* + * Il pulsante e i layer Leaflet vengono + * gestiti soltanto in modalità standard. + */ + if (!isMapbox3D) { + if (!activityRoutesLayer) { + activityRoutesLayer = L.layerGroup().addTo(window.globalMap); + } + + activityRoutesLayer.clearLayers(); + + clearSelectedActivityOnly(); + } else { + /* + * Durante la transizione al 3D pulisce + * eventuali residui senza richiedere + * che Leaflet sia ancora disponibile. + */ + activityRoutesLayer?.clearLayers?.(); + + clearSelectedActivityOnly(); + } + + // ================================= + // RECUPERA LE ATTIVITÀ + // ================================= + + const items = mapActivityItems; + + const activityItems = items.filter(isProbablyActivity); + + cachedRouteData = []; + + // ================================= + // CARICA I PUNTI DEI PERCORSI + // ================================= + + const routeDataList = await Promise.all( + activityItems.map(async (item, index) => { + const points = await getRoutePointsSafe(item); + + if (!Array.isArray(points) || points.length < 2) { + return null; + } + + const latlngs = pointsToLatLngs(points); + + if (!Array.isArray(latlngs) || latlngs.length < 2) { + return null; + } + + return { + key: getActivityKey(item, index), + + index, + item, + points, + latlngs, + + start: latlngs[0], + + end: latlngs[latlngs.length - 1], + + cells: getRouteCells(points), + }; + }), + ); + + /* + * Se nel frattempo è iniziato un altro + * rendering, questo risultato è obsoleto. + */ +if ( + token !== lastRenderToken || + window.state?.mode !== + renderMode +) { + return; +} + + cachedRouteData = routeDataList.filter(Boolean); + + console.log("[activitiesLayer] route caricate", { + inputItems: items.length, + + activityItems: activityItems.length, + + routeResults: routeDataList.length, + + validRoutes: cachedRouteData.length, + + isMapbox3D, + }); + + // ================================= + // MODALITÀ MAPBOX 3D + // ================================= + + if (isMapbox3D) { + activityRoutesLayer?.clearLayers?.(); + clearSelectedActivityOnly(); + + const activityFeatures = + typeof window.getActivityMapGeoJSON === "function" + ? window.getActivityMapGeoJSON() + : []; + + const routeFeatures = + typeof window.getActivityRoutesGeoJSON === "function" + ? window.getActivityRoutesGeoJSON() + : []; + + console.log("[activitiesLayer] invio dati a Mapbox", { + cachedActivities: cachedRouteData.length, + + activityFeatures: activityFeatures.length, + + routeFeatures: routeFeatures.length, + }); + + if (typeof window.MapboxMarkers?.setActivities === "function") { + window.MapboxMarkers.setActivities(activityFeatures); + } else { + console.warn( + "[activitiesLayer] MapboxMarkers.setActivities non disponibile", + ); + } + + if (typeof window.MapboxMarkers?.setRoutes === "function") { + window.MapboxMarkers.setRoutes( + showAllActivityRoutes ? routeFeatures : [], + ); + } + } else { + // ================================= + // MODALITÀ LEAFLET + // ================================= + + console.log("[activitiesLayer] rendering marker Leaflet", { + routes: cachedRouteData.length, + + globalMap: !!window.globalMap, + + globalMarkers: !!window.globalMarkers, + + mapEngineType: window.MapEngine?.type, + + mapEngineMapIsLeaflet: window.MapEngine?.map === window.globalMap, + + markerLayerIsGlobalMarkers: + window.MapEngine?.markerLayer === window.globalMarkers, + }); + + if ( + window.globalMap && + window.globalMarkers && + !window.globalMap.hasLayer(window.globalMarkers) + ) { + console.warn( + "[activitiesLayer] cluster group non presente, lo ripristino", + ); + + window.globalMarkers.addTo(window.globalMap); + } + + for (const route of cachedRouteData) { + const marker = L.marker(route.start, { + icon: createActivityStartIcon(route.item), + + title: + route.item.display_name || + route.item.route_name || + route.item.name || + route.item.file_name || + "", + }); + + marker.__item = route.item; + + marker.__routeData = route; + +marker.on( + "click", + (event) => { + event?.originalEvent + ?.preventDefault?.(); + + event?.originalEvent + ?.stopPropagation?.(); + + const activityList = + cachedRouteData.map( + (routeData) => + routeData.item, + ); + + const activityIndex = + cachedRouteData.findIndex( + (routeData) => + routeData.key === + route.key, + ); + + window.openActivityModal?.( + route.item, + activityList, + activityIndex >= 0 + ? activityIndex + : 0, + ); + }, +); + + if (window.globalMarkers?.addLayer) { + window.globalMarkers.addLayer(marker); + } else { + console.warn("[activitiesLayer] globalMarkers non disponibile", { + globalMarkers: window.globalMarkers, + + mapEngineType: window.MapEngine?.type, + + markerLayer: window.MapEngine?.markerLayer, + }); + } + + activityStartMarkers.push(marker); + } + + + console.log("[activitiesLayer] marker Leaflet aggiunti", { + trackedMarkers: activityStartMarkers.length, + + clusterLayers: window.globalMarkers?.getLayers?.()?.length ?? null, + + clusterOnMap: !!( + window.globalMap && + window.globalMarkers && + window.globalMap.hasLayer(window.globalMarkers) + ), + + mode: window.state?.mode, + }); + + } + + // ================================= + // ADATTA LA MAPPA ALLE ATTIVITÀ + // ================================= + +if ( + token !== lastRenderToken || + window.state?.mode !== + renderMode +) { + return; +} + + /* + * Non eseguire fitBounds durante il rendering + * automatico: moveend richiama questo renderer + * e potrebbe innescare caricamenti ripetuti. + * L'inquadramento della singola attività resta + * disponibile nel relativo dettaglio. + */ + + // ================================= + // MOSTRA TUTTE LE TRACCE + // ================================= + + /* + * Le tracce Leaflet devono essere + * renderizzate soltanto nella modalità + * standard. In 3D vengono gestite dalla + * source routes di Mapbox. + */ + if (showAllActivityRoutes && !isMapbox3D) { + await renderAllActivityRoutes(); + } + +if ( + token !== lastRenderToken || + window.state?.mode !== + renderMode +) { + return; +} + + + console.log("[activitiesLayer] render completato", { + mode: isMapbox3D ? "mapbox-3d" : "standard", + + activities: cachedRouteData.length, + + token, + }); + }; + + // =============================== + // TRACCIA SELEZIONATA + // =============================== + function showSelectedActivityOnGlobalMap(route) { + clearSelectedActivityOnly(); + + selectedActivityLayer = L.polyline(route.latlngs, { + color: "#ff6b00", + weight: 5, + opacity: 1, + lineCap: "round", + lineJoin: "round", + }).addTo(window.globalMap); + + selectedActivityLayer.on("click", () => { + window.openActivityModal?.(route.item); + }); + + selectedActivityEndMarker = L.circleMarker(route.end, { + radius: 7, + color: "#b00020", + weight: 2, + fillColor: "#e32636", + fillOpacity: 1, + }) + .bindTooltip("Arrivo") + .addTo(window.globalMap); + + selectedActivityEndMarker.on("click", () => { + window.openActivityModal?.(route.item); + }); + + const popupHtml = createActivityPopupHtml(route.item); + + selectedActivityLayer.bindPopup(popupHtml).openPopup(); + + window.globalMap.fitBounds(selectedActivityLayer.getBounds(), { + padding: [50, 50], + maxZoom: 15, + }); + + setTimeout(() => { + const btn = document.querySelector( + `[data-open-activity-key="${route.key}"]`, + ); + + btn?.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + + window.openActivityModal?.(route.item); + }); + }, 0); + } + + function clearSelectedActivityOnly() { + if (selectedActivityLayer) { + selectedActivityLayer.remove(); + selectedActivityLayer = null; + } + + if (selectedActivityEndMarker) { + selectedActivityEndMarker.remove(); + selectedActivityEndMarker = null; + } + } + + function updateMapboxActivityRoutes() { + if (!window.MapTerrain3D?.enabled?.()) { + return; + } + + if (typeof window.MapboxMarkers?.setRoutes !== "function") { + console.warn("[activitiesLayer] MapboxMarkers.setRoutes non disponibile"); + + return; + } + + if (!showAllActivityRoutes) { + window.MapboxMarkers.setRoutes([]); + + console.log("[activitiesLayer] tracce Mapbox nascoste"); + + return; + } + + const routeFeatures = + typeof window.getActivityRoutesGeoJSON === "function" + ? window.getActivityRoutesGeoJSON() + : []; + + window.MapboxMarkers.setRoutes(routeFeatures); + + console.log("[activitiesLayer] tracce Mapbox visibili", { + routes: routeFeatures.length, + }); + } + + // =============================== + // TOGGLE TUTTE LE TRACCE + // =============================== + async function renderAllActivityRoutes() { + if (!activityRoutesLayer) { + activityRoutesLayer = L.layerGroup().addTo(window.globalMap); + } + + activityRoutesLayer.clearLayers(); + + if (!showAllActivityRoutes) return; + + let routesToDraw = cachedRouteData; + + if (routesToDraw.length > MAX_GLOBAL_ACTIVITY_ROUTES) { + const bounds = window.globalMap.getBounds(); + + routesToDraw = routesToDraw.filter((route) => + bounds.contains(route.start), + ); + } + + if (routesToDraw.length > MAX_GLOBAL_ACTIVITY_ROUTES) { + console.warn( + `[activity map] Troppe tracce da disegnare: ${routesToDraw.length}. Zooma di più.`, + ); + return; + } + + const colors = assignSmartRouteColors(routesToDraw); + + routesToDraw.forEach((route) => { + const color = + colors.get(route.key) || + ACTIVITY_ROUTE_COLORS[route.index % ACTIVITY_ROUTE_COLORS.length]; + + const polyline = L.polyline(route.latlngs, { + color, + weight: 5, + opacity: 0.75, + lineCap: "round", + lineJoin: "round", + }); + + polyline.on("click", () => { + showSelectedActivityOnGlobalMap(route); + }); + + activityRoutesLayer.addLayer(polyline); + }); + } + + // =============================== + // COLORI INTELLIGENTI + // =============================== + function assignSmartRouteColors(routes) { + const cellToColors = new Map(); + const result = new Map(); + + routes.forEach((route, index) => { + const usedNearbyColors = new Set(); + + route.cells.forEach((cell) => { + const colors = cellToColors.get(cell); + + if (!colors) return; + + colors.forEach((color) => usedNearbyColors.add(color)); + }); + + const color = + ACTIVITY_ROUTE_COLORS.find((c) => !usedNearbyColors.has(c)) || + ACTIVITY_ROUTE_COLORS[index % ACTIVITY_ROUTE_COLORS.length]; + + result.set(route.key, color); + + route.cells.forEach((cell) => { + if (!cellToColors.has(cell)) { + cellToColors.set(cell, new Set()); + } + + cellToColors.get(cell).add(color); + }); + }); + + return result; + } + + function getRouteCells(points, precision = 3) { + const cells = new Set(); + + points.forEach((point) => { + const lat = Number(point.lat); + const lng = Number(point.lng); + + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; + + cells.add(`${lat.toFixed(precision)}:${lng.toFixed(precision)}`); + }); + + return cells; + } + + // =============================== + // HELPERS + // =============================== + function isProbablyActivity(item) { + if (!item) return false; + + const format = String( + item.format || + item.file_extension || + item.mime_type || + item.file_name || + item.name || + "", + ).toLowerCase(); + + return ( + format.includes("fit") || + format.includes("gpx") || + format.includes("tcx") || + item.distance_meters != null || + item.duration_millis != null || + item.track_preview != null + ); + } + + async function getRoutePointsSafe(item) { + try { + if (typeof window.getActivityRoutePoints === "function") { + return await window.getActivityRoutePoints(item); + } + + if (typeof getActivityRoutePoints === "function") { + return await getActivityRoutePoints(item); + } + } catch (error) { + console.warn("[activity map] errore caricamento punti:", error); + } + + return []; + } + + function pointsToLatLngs(points) { + return points + .map((point) => [Number(point.lat), Number(point.lng)]) + .filter( + (point) => Number.isFinite(point[0]) && Number.isFinite(point[1]), + ); + } + + function getActivityKey(item, index) { + const value = + item?.id ?? item?.path ?? item?.file_name ?? item?.name ?? index; + + return String(value); + } + + window.getActivityMapKey = getActivityKey; + + function createActivityPopupHtml(item) { +const key = + escapeHtml( + getActivityKey( + item, + "", + ), + ); + + const title = + item.display_name || + item.route_name || + item.title || + item.file_name || + item.name || + "Attività"; + + const distance = Number(item.distance_meters); + const duration = Number(item.duration_millis); + + const distanceText = Number.isFinite(distance) + ? `${(distance / 1000).toFixed(1)} km` + : "-"; + + const durationText = + Number.isFinite(duration) && + typeof window.formatDurationShort === "function" + ? window.formatDurationShort(duration) + : formatDurationFallback(duration); + + return ` +
+ ${escapeHtml(title)} + + ${item.sport ? `
${escapeHtml(item.sport)}
` : ""} + +
${distanceText} · ${durationText}
+ + +
+ `; + } + + function formatDurationFallback(milliseconds) { + const value = Number(milliseconds); + + if (!Number.isFinite(value)) return "-"; + + const totalSeconds = Math.round(value / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; + } + + function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + // =============================== + // GEOJSON MAPBOX ATTIVITÀ + // =============================== + window.getActivityMapGeoJSON = function () { + const features = []; + + for (const route of cachedRouteData) { + if (!route.start) { + continue; + } + + features.push({ + type: "Feature", + + geometry: { + type: "Point", + + // route.start = [lat, lng] + coordinates: [ + route.start[1], // lng + route.start[0], // lat + ], + }, + + properties: { + id: route.key, + type: "activity", + + name: + route.item.display_name || + route.item.route_name || + route.item.name || + "Attività", + + sport: route.item.sport, + distance: route.item.distance_meters, + duration: route.item.duration_millis, + }, + }); + } + + return features; + }; + + // =============================== + // LINEE PERCORSI MAPBOX + // =============================== + window.getActivityRoutesGeoJSON = function () { + const features = []; + + for (const route of cachedRouteData) { + if (!route.latlngs || route.latlngs.length < 2) { + continue; + } + + features.push({ + type: "Feature", + + geometry: { + type: "LineString", + + // route.latlngs contiene [lat, lng] + coordinates: route.latlngs.map((p) => [ + p[1], // lng + p[0], // lat + ]), + }, + + properties: { + id: route.key, + type: "route", + }, + }); + } + + return features; + }; + + window.areActivityRoutesVisible = function () { + return showAllActivityRoutes; + }; +})(); diff --git a/public/js/map/baseLayers.js b/public/js/map/baseLayers.js new file mode 100644 index 0000000..da2cb91 --- /dev/null +++ b/public/js/map/baseLayers.js @@ -0,0 +1,548 @@ +// public/js/mapBaseLayers.js +// Basemap 2D Leaflet e selettore livelli. + +(() => { + "use strict"; + + const STORAGE_KEY = "selectedMapStyle"; + const DEFAULT_STYLE = "osm"; + + let currentLayer = null; + let layers = {}; + let layerControl = null; + let initializedMap = null; + let terrainPlaceholderLayer = null; + let terrainStyleControl = null; + let styleChangeInProgress = false; + + function getMapboxToken() { + return window.AppConfig?.mapboxToken || ""; + } + + function createOsmLayer() { + return L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + subdomains: "abc", + maxZoom: 19, + attribution: "© OpenStreetMap contributors", + }); + } + + function createOpenTopoLayer() { + return L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", { + subdomains: "abc", + maxZoom: 17, + attribution: + "Map data: © OpenStreetMap contributors, SRTM | " + + "Map style: © OpenTopoMap", + }); + } + + function createMapboxLayer(styleId, token) { + if (!token) { + return null; + } + + const encodedToken = encodeURIComponent(token); + + const tileUrl = + `https://api.mapbox.com/styles/v1/mapbox/${styleId}` + + `/tiles/512/{z}/{x}/{y}@2x` + + `?access_token=${encodedToken}`; + + return L.tileLayer(tileUrl, { + tileSize: 512, + zoomOffset: -1, + maxZoom: 22, + attribution: "© Mapbox © OpenStreetMap contributors", + }); + } + + function createLayers() { + const token = getMapboxToken(); + + const result = { + osm: { + label: "OpenStreetMap", + layer: createOsmLayer(), + engine: "leaflet", + }, + + opentopo: { + label: "Topografica", + layer: createOpenTopoLayer(), + engine: "leaflet", + }, + }; + + if (!token) { + console.warn( + "[mapBaseLayers] Token Mapbox non configurato. " + + "Saranno disponibili solamente OpenStreetMap " + + "e OpenTopoMap.", + ); + + return result; + } + + const mapboxDefinitions = { + mapboxStreets: { + label: "Mapbox Streets", + styleId: "streets-v12", + }, + + mapboxOutdoors: { + label: "Mapbox Outdoors", + styleId: "outdoors-v12", + }, + + mapboxSatellite: { + label: "Mapbox Satellite", + styleId: "satellite-v9", + }, + + mapboxSatelliteStreets: { + label: "Mapbox Satellite + strade", + styleId: "satellite-streets-v12", + }, + }; + + for (const [id, definition] of Object.entries(mapboxDefinitions)) { + const layer = createMapboxLayer(definition.styleId, token); + + if (!layer) { + continue; + } + + result[id] = { + label: definition.label, + layer, + }; + } + + terrainPlaceholderLayer = L.layerGroup(); + + result.terrain3d = { + label: "Terrain 3D", + layer: terrainPlaceholderLayer, + engine: "mapbox", + }; + + return result; + } + + function getSavedStyle() { + const savedStyle = localStorage.getItem(STORAGE_KEY); + + if (savedStyle && layers[savedStyle]) { + return savedStyle; + } + + return DEFAULT_STYLE; + } + + function findStyleIdByLayer(selectedLayer) { + for (const [styleId, definition] of Object.entries(layers)) { + if (definition.layer === selectedLayer) { + return styleId; + } + } + + return null; + } + + function dispatchStyleChange(styleId, label) { + const definition = layers[styleId]; + + window.dispatchEvent( + new CustomEvent("mapstylechange", { + detail: { + engine: definition?.engine || "leaflet", + + styleId, + label, + }, + }), + ); + } + + async function setStyle(map, styleId, save = true) { + if (!map) { + console.error("[mapBaseLayers] Istanza Leaflet mancante"); + + return false; + } + + if (styleChangeInProgress) { + return false; + } + + const selectedStyle = layers[styleId] ? styleId : DEFAULT_STYLE; + + const definition = layers[selectedStyle]; + + if (!definition) { + console.error("[mapBaseLayers] Basemap non trovata:", styleId); + + return false; + } + + styleChangeInProgress = true; + + try { + // =============================== + // TERRAIN 3D + // =============================== + + if (selectedStyle === "terrain3d") { + const success = await window.MapTerrain3D?.enable?.(); + + if (!success) { + console.warn("[mapBaseLayers] Terrain 3D non attivato"); + + return false; + } + + currentLayer = terrainPlaceholderLayer; + + if (save) { + localStorage.setItem(STORAGE_KEY, selectedStyle); + } + + syncTerrainStyleControl(selectedStyle); + + dispatchStyleChange(selectedStyle, definition.label); + + return true; + } + + // =============================== + // MAPPA LEAFLET + // =============================== + + if (window.MapTerrain3D?.enabled?.()) { + window.MapTerrain3D?.disable?.(); + } + + if ( + currentLayer && + currentLayer !== terrainPlaceholderLayer && + map.hasLayer(currentLayer) + ) { + map.removeLayer(currentLayer); + } + + if (terrainPlaceholderLayer && map.hasLayer(terrainPlaceholderLayer)) { + map.removeLayer(terrainPlaceholderLayer); + } + + currentLayer = definition.layer; + + if (currentLayer && !map.hasLayer(currentLayer)) { + currentLayer.addTo(map); + } + + if (save) { + localStorage.setItem(STORAGE_KEY, selectedStyle); + } + + syncTerrainStyleControl(selectedStyle); + + dispatchStyleChange(selectedStyle, definition.label); + + return true; + } finally { + styleChangeInProgress = false; + } + } + + function createControl(map) { + if (layerControl) { + map.removeControl(layerControl); + layerControl = null; + } + + const baseLayers = {}; + + for (const definition of Object.values(layers)) { + baseLayers[definition.label] = definition.layer; + } + + layerControl = L.control.layers(baseLayers, null, { + position: "topright", + collapsed: true, + }); + + layerControl.addTo(map); + + map.on("baselayerchange", handleBaseLayerChange); + } + + async function handleBaseLayerChange(event) { + const styleId = findStyleIdByLayer(event.layer); + + if (!styleId) { + return; + } + + // =============================== + // TERRAIN 3D + // =============================== + + if (styleId === "terrain3d") { + const success = await setStyle(initializedMap, "terrain3d", true); + + if (!success) { + await setStyle(initializedMap, DEFAULT_STYLE, true); + } + + return; + } + + // =============================== + // LEAFLET + // =============================== + + currentLayer = event.layer; + + if (window.MapTerrain3D?.enabled?.()) { + window.MapTerrain3D?.disable?.(); + } + + localStorage.setItem(STORAGE_KEY, styleId); + + syncTerrainStyleControl(styleId); + + dispatchStyleChange(styleId, event.name); + } + + async function waitForConfiguration() { + if (window.AppConfigPromise) { + try { + await window.AppConfigPromise; + } catch (error) { + console.error("[mapBaseLayers] Errore configurazione:", error); + } + } + + return window.AppConfig || {}; + } + + function getSelectedStyle() { + if (window.MapTerrain3D?.enabled?.()) { + return "terrain3d"; + } + + return findStyleIdByLayer(currentLayer) || DEFAULT_STYLE; + } + + function syncTerrainStyleControl(styleId) { + const select = terrainStyleControl?.querySelector?.("select"); + + if (!select) { + return; + } + + if (layers[styleId]) { + select.value = styleId; + } + } + + function removeTerrainStyleControl() { + if (!terrainStyleControl) { + return; + } + + terrainStyleControl.remove(); + terrainStyleControl = null; + } + + function mountTerrainStyleControl(targetContainer) { + if (!targetContainer) { + return; + } + + removeTerrainStyleControl(); + + const wrapper = document.createElement("div"); + + wrapper.className = "terrain-style-control"; + + const select = document.createElement("select"); + + select.className = "terrain-style-select"; + + select.setAttribute("aria-label", "Tipo di mappa"); + + for (const [id, definition] of Object.entries(layers)) { + const option = document.createElement("option"); + + option.value = id; + option.textContent = definition.label; + + select.appendChild(option); + } + + select.value = getSelectedStyle(); + + select.addEventListener("change", async () => { + const selectedStyle = select.value; + + const success = await setStyle(initializedMap, selectedStyle, true); + + if (!success) { + select.value = getSelectedStyle(); + } + }); + + wrapper.appendChild(select); + + targetContainer.appendChild(wrapper); + + terrainStyleControl = wrapper; + } + + async function initialize(map) { + if (!map) { + console.error("[mapBaseLayers] Istanza mappa non disponibile"); + + return false; + } + + if (typeof window.L === "undefined") { + console.error("[mapBaseLayers] Leaflet non disponibile"); + + return false; + } + + await waitForConfiguration(); + + /* + * Evita una doppia inizializzazione sulla stessa + * istanza Leaflet. + */ + if (initializedMap === map && Object.keys(layers).length > 0) { + return true; + } + + /* + * Se il modulo viene inizializzato su una nuova mappa, + * rimuove il listener dalla precedente. + */ + if (initializedMap && initializedMap !== map) { + initializedMap.off("baselayerchange", handleBaseLayerChange); + } + + initializedMap = map; + layers = createLayers(); + + const initialStyle = getSavedStyle(); + + const safeInitialStyle = + initialStyle === "terrain3d" ? DEFAULT_STYLE : initialStyle; + + const styleApplied = await setStyle(map, safeInitialStyle, false); + + createControl(map); + + console.log("[mapBaseLayers] Basemap inizializzate", { + currentStyle: getCurrentStyle(), + availableStyles: getAvailableStyles().map((style) => style.id), + mapboxConfigured: Boolean(getMapboxToken()), + }); + + return styleApplied; + } + + function getCurrentStyle() { + if (window.MapTerrain3D?.enabled?.()) { + return "terrain3d"; + } + + return findStyleIdByLayer(currentLayer) || DEFAULT_STYLE; + } + + function getAvailableStyles() { + return Object.entries(layers).map(([id, definition]) => ({ + id, + label: definition.label, + })); + } + + function createLeafletLayer( + styleId, +) { + const token = + getMapboxToken(); + + switch (styleId) { + case "osm": + return createOsmLayer(); + + case "opentopo": + return createOpenTopoLayer(); + + case "mapboxStreets": + return createMapboxLayer( + "streets-v12", + token, + ); + + case "mapboxOutdoors": + return createMapboxLayer( + "outdoors-v12", + token, + ); + + case "mapboxSatellite": + return createMapboxLayer( + "satellite-v9", + token, + ); + + case "mapboxSatelliteStreets": + return createMapboxLayer( + "satellite-streets-v12", + token, + ); + + default: + return createOsmLayer(); + } +} + +function getMapboxStyleUrl( + styleId, +) { + switch (styleId) { + case "mapboxStreets": + return "mapbox://styles/mapbox/streets-v12"; + + case "mapboxSatellite": + return "mapbox://styles/mapbox/satellite-v9"; + + case "mapboxSatelliteStreets": + return "mapbox://styles/mapbox/satellite-streets-v12"; + + case "terrain3d": + case "mapboxOutdoors": + default: + return "mapbox://styles/mapbox/outdoors-v12"; + } +} + +window.MapBaseLayers = + Object.freeze({ + initialize, + setStyle, + + getCurrentStyle, + getAvailableStyles, + + createLeafletLayer, + getMapboxStyleUrl, + + mountTerrainStyleControl, + removeTerrainStyleControl, + }); + +})(); diff --git a/public/js/map/clusterIcons.js b/public/js/map/clusterIcons.js new file mode 100644 index 0000000..62764e8 --- /dev/null +++ b/public/js/map/clusterIcons.js @@ -0,0 +1,141 @@ +// public/js/map/clusterIcons.js +// =============================== +// CLUSTER ICONS +// =============================== + +(function () { + "use strict"; + + function getFirstMarkers(cluster, limit = 4) { + const result = []; + const stack = [cluster]; + + while (stack.length && result.length < limit) { + const node = stack.pop(); + + if (node._markers?.length) { + for (const marker of node._markers) { + result.push(marker); + + if (result.length >= limit) { + break; + } + } + } + + if (result.length >= limit) { + break; + } + + if (node._childClusters?.length) { + for (let i = node._childClusters.length - 1; i >= 0; i--) { + stack.push(node._childClusters[i]); + } + } + } + + return result; + } + + window.createPhotoClusterHtml = function ({ count, thumbs = [] }) { + const size = Math.min(92, Math.round(28 + Math.sqrt(count) * 6)); + + const cls = + count > 200 + ? "cluster-xl" + : count > 50 + ? "cluster-lg" + : count > 10 + ? "cluster-md" + : "cluster-sm"; + + const collage = thumbs.length + ? ` +
+ ${thumbs + .map( + (src, index) => ` +
+ +
+ `, + ) + .join("")} +
+ ` + : ""; + + return { + size, + html: ` +
+ ${collage} +
+ ${count} +
+
+ `, + }; + }; + + function getPhotoClusterData(cluster) { + const count = cluster.getChildCount(); + + const size = Math.min(92, Math.round(28 + Math.sqrt(count) * 6)); + + const cls = + count > 200 + ? "cluster-xl" + : count > 50 + ? "cluster-lg" + : count > 10 + ? "cluster-md" + : "cluster-sm"; + + const thumbs = getFirstMarkers(cluster) + .map((marker) => marker.__thumb) + .filter(Boolean); + + return { + count, + size, + cls, + thumbs, + }; + } + + window.createPhotoClusterIcon = function (cluster) { + const { count, size, cls, thumbs } = getPhotoClusterData(cluster); + + const collage = thumbs.length + ? ` +
+ ${thumbs + .map( + (src, index) => ` +
+ +
+ `, + ) + .join("")} +
+ ` + : ""; + + return L.divIcon({ + html: ` +
+ ${collage} +
+ ${count} +
+
+ `, + className: "marker-cluster-wrapper", + iconSize: L.point(size, size), + }); + }; +})(); diff --git a/public/js/map/engine.js b/public/js/map/engine.js new file mode 100644 index 0000000..f07ba7a --- /dev/null +++ b/public/js/map/engine.js @@ -0,0 +1,146 @@ +// public/js/map/engine.js +// =============================== +// ASTRAZIONE MOTORE MAPPA +// =============================== + +(function () { + const engine = { + type: "leaflet", + + leafletMap: null, + leafletMarkerLayer: null, + + mapboxMap: null, + mapboxMoveEndHandler: null, + + map: null, + markerLayer: null, + + init(map, markerLayer) { + this.type = "leaflet"; + + this.leafletMap = map; + this.leafletMarkerLayer = markerLayer; + + this.map = map; + this.markerLayer = markerLayer; + }, +/* + setMapbox(mapboxMap) { + this.type = "mapbox"; + + this.mapboxMap = mapboxMap; + + this.map = mapboxMap; + this.markerLayer = null; + + mapboxMap.on("moveend", () => { + console.log("[MapEngine] Mapbox moveend -> aggiorno foto"); + + window.renderPhotoMapLayer?.(); + }); + }, +*/ +setMapbox(mapboxMap) { + if (this.mapboxMap && this.mapboxMoveEndHandler) { + this.mapboxMap.off("moveend", this.mapboxMoveEndHandler); + } + + this.type = "mapbox"; + this.mapboxMap = mapboxMap; + this.map = mapboxMap; + this.markerLayer = null; + + this.mapboxMoveEndHandler = () => { + window.renderPhotoMapLayer?.(); + }; + mapboxMap.on("moveend", this.mapboxMoveEndHandler); + + console.log("[MapEngine] Mapbox collegato"); + }, + + setLeaflet() { + this.type = "leaflet"; + + this.map = this.leafletMap; + this.markerLayer = this.leafletMarkerLayer; + }, + + isLeaflet() { + return this.type === "leaflet"; + }, + + isMapbox() { + return this.type === "mapbox"; + }, + + isReady() { + return !!this.map; + }, + + addMarker(marker) { + if (!marker || !this.markerLayer) { + return; + } + + this.markerLayer.addLayer(marker); + }, + + addMarkers(markers) { + if (!markers?.length || !this.markerLayer) { + return; + } + + this.markerLayer.addLayers(markers); + }, + + clearMarkers() { + this.markerLayer?.clearLayers(); + }, + /* + getBounds() { + return this.map?.getBounds?.(); + }, +*/ + getBounds() { + if (this.isMapbox()) { + const b = this.mapboxMap?.getBounds?.(); + + if (!b) { + return null; + } + + return { + getWest: () => b.getWest(), + getEast: () => b.getEast(), + getSouth: () => b.getSouth(), + getNorth: () => b.getNorth(), + }; + } + + return this.leafletMap?.getBounds?.() || null; + }, + + getZoom() { + return this.map?.getZoom?.(); + }, + + fitBounds(bounds, options = {}) { + this.map?.fitBounds?.(bounds, options); + }, + + invalidateSize() { + this.map?.invalidateSize?.(); + }, + + on(event, callback) { + this.map?.on?.(event, callback); + }, + + off(event, callback) { + this.map?.off?.(event, callback); + }, + }; + + window.MapEngine = Object.seal(engine); +})(); diff --git a/public/js/map/globalMap.js b/public/js/map/globalMap.js new file mode 100644 index 0000000..f80bb37 --- /dev/null +++ b/public/js/map/globalMap.js @@ -0,0 +1,463 @@ +// public/js/mapGlobal.js +// =============================== +// MAPPA GLOBALE +// Controller generico Leaflet + MarkerCluster +// =============================== + +window.globalMap = null; +window.globalMarkers = null; + +document.addEventListener("DOMContentLoaded", () => { + "use strict"; + + const openBtn = document.getElementById("openMapBtn"); + + if (!openBtn) { + console.error("[mapGlobal] Pulsante #openMapBtn non trovato"); + + return; + } + + const DISABLE_CLUSTER_AT_ZOOM = 18; + const OPEN_STRIP_CHILDREN_MAX = 20; + + let mapInitializationPromise = null; + + openBtn.addEventListener("click", openGlobalMap); + + // =============================== + // APRI / CHIUDI MAPPA + // =============================== + async function openGlobalMap() { + const mapDiv = document.getElementById("globalMap"); + + const terrainDiv = document.getElementById("terrainMap"); + + const gallery = document.getElementById("gallery"); + + if (!mapDiv) { + console.error("[mapGlobal] Contenitore #globalMap non trovato"); + + return; + } + + const leafletOpen = mapDiv.classList.contains("open"); + + const terrainOpen = + !!window.MapTerrain3D?.enabled?.() || + terrainDiv?.classList.contains("open") || + false; + + const anyMapOpen = leafletOpen || terrainOpen; + + // ================================= + // CHIUDE QUALSIASI MAPPA + // ================================= + + if (anyMapOpen) { + if (window.MapTerrain3D?.enabled?.()) { + window.MapTerrain3D.disable(); + } + + // Nasconde Leaflet, che disable() + // potrebbe aver appena ripristinato. + mapDiv.classList.remove("open"); + + mapDiv.style.display = "none"; + + mapDiv.style.visibility = "hidden"; + + mapDiv.style.pointerEvents = "none"; + + // Nasconde anche Mapbox 3D. + if (terrainDiv) { + terrainDiv.classList.remove("open"); + + terrainDiv.style.display = "none"; + + terrainDiv.style.visibility = "hidden"; + + terrainDiv.style.pointerEvents = "none"; + } + + // Mostra la galleria. + gallery?.classList.remove("hidden"); + + // Ricalcola le miniature dopo il ritorno dalla mappa. + window.GalleryRenderer?.refreshLayout?.(); + + document.body.classList.remove("map-open"); + + window.closeBottomSheet?.(); + + window.clearActivityMapLayers?.(); + + window.hideActivityRoutesToggle?.(); + + console.log("[mapGlobal] ritorno alla galleria"); + + return; + } + + // ================================= + // APRE LA MAPPA LEAFLET + // ================================= + + mapDiv.style.display = "block"; + + mapDiv.style.visibility = "visible"; + + mapDiv.style.pointerEvents = "auto"; + + mapDiv.classList.add("open"); + + gallery?.classList.add("hidden"); + + document.body.classList.add("map-open"); + + /* + * Attende che il browser renda + * visibile il contenitore. + */ + await new Promise((resolve) => { + requestAnimationFrame(resolve); + }); + + try { + if (!window.globalMap) { + /* + * Impedisce due inizializzazioni + * contemporanee in caso di click + * ripetuti. + */ + if (!mapInitializationPromise) { + mapInitializationPromise = initGlobalMap(); + } + + await mapInitializationPromise; + } else { + window.globalMap.invalidateSize(); + } + + await window.redrawGlobalMap?.(); + + const savedMapStyle = localStorage.getItem("selectedMapStyle"); + + if (savedMapStyle === "terrain3d") { + const terrainSuccess = await window.MapBaseLayers?.setStyle?.( + window.globalMap, + "terrain3d", + false, + ); + + console.log("[mapGlobal] ripristino Terrain 3D", { + success: terrainSuccess, + + savedMapStyle, + }); + } + } catch (error) { + console.error("[mapGlobal] Errore apertura mappa:", error); + + mapInitializationPromise = null; + + mapDiv.classList.remove("open"); + + gallery?.classList.remove("hidden"); + + document.body.classList.remove("map-open"); + } + } + + window.openGlobalMap = openGlobalMap; + + // =============================== + // INIZIALIZZA MAPPA + // =============================== + async function initGlobalMap() { + console.log("[mapGlobal] Inizializzo Leaflet + MarkerCluster"); + + if (typeof window.L === "undefined") { + throw new Error("Leaflet non è stato caricato"); + } + + if (typeof window.L.markerClusterGroup !== "function") { + throw new Error("Leaflet MarkerCluster non è stato caricato"); + } + + window.globalMap = L.map("globalMap", { + zoomControl: true, + attributionControl: true, + }).setView([42.5, 12.5], 6); + + window.MapEngine?.init(window.globalMap); + + // =============================== + // BASEMAP + // =============================== + if ( + window.MapBaseLayers && + typeof window.MapBaseLayers.initialize === "function" + ) { + /* + * Attende che config.js abbia recuperato + * il token Mapbox da /config. + */ + await window.MapBaseLayers.initialize(window.globalMap); + } else { + console.error( + "[mapGlobal] MapBaseLayers non disponibile. " + + "Uso OpenStreetMap come fallback.", + ); + + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + subdomains: "abc", + maxZoom: 19, + attribution: "© OpenStreetMap contributors", + }).addTo(window.globalMap); + } + + // =============================== + // MARKER CLUSTER + // =============================== + window.globalMarkers = L.markerClusterGroup({ + showCoverageOnHover: false, + spiderfyOnMaxZoom: true, + disableClusteringAtZoom: DISABLE_CLUSTER_AT_ZOOM, + iconCreateFunction: clusterIconRenderer, + }); + + window.MapEngine?.init?.(window.globalMap, window.globalMarkers); + + window.globalMarkers.on("clusterclick", onClusterClick); + + window.globalMap.addLayer(window.globalMarkers); + + window.MapEngine?.init(window.globalMap, window.globalMarkers); + + // =============================== + // AGGIORNAMENTO BOTTOM SHEET + // =============================== + window.globalMap.on("moveend", refreshOpenBottomSheet); + + window.globalMap.on("moveend", () => { + if (window.state?.mode === "photos") { + window.renderPhotoMapLayer?.(); + } else if (window.state?.mode === "activities") { + window.renderActivityMapLayer?.(); + } + }); + + /* + * Leaflet viene creato mentre il contenitore + * è appena diventato visibile. Un secondo + * invalidateSize evita layout incompleti. + */ + await new Promise((resolve) => { + requestAnimationFrame(resolve); + }); + + window.globalMap.invalidateSize(); + + console.log("[mapGlobal] Mappa inizializzata", { + currentStyle: window.MapBaseLayers?.getCurrentStyle?.() || "fallback-osm", + + styles: window.MapBaseLayers?.getAvailableStyles?.() || [], + }); + + return window.globalMap; + } + + // =============================== + // AGGIORNA BOTTOM SHEET DOPO MOVE + // =============================== + function refreshOpenBottomSheet() { + const bottomSheet = document.getElementById("bottomSheet"); + + if (!bottomSheet?.classList.contains("open")) { + return; + } + + if (!window.globalMap || !window.globalMarkers) { + return; + } + + const bounds = window.globalMap.getBounds(); + + const items = []; + + window.globalMarkers.eachLayer((marker) => { + if (!marker.__item) { + return; + } + + if (typeof marker.getLatLng !== "function") { + return; + } + + if (bounds.contains(marker.getLatLng())) { + items.push(marker.__item); + } + }); + + if (items.length > 1) { + window.refreshBottomSheet?.(items); + } + } + + // =============================== + // CLUSTER ICON GENERICO + // =============================== + function clusterIconRenderer(cluster) { + const mode = window.state?.mode; + + if ( + mode === "photos" && + typeof window.createPhotoClusterIcon === "function" + ) { + return window.createPhotoClusterIcon(cluster); + } + + if ( + mode === "activities" && + typeof window.createActivityClusterIcon === "function" + ) { + return window.createActivityClusterIcon(cluster); + } + + const count = cluster.getChildCount(); + + console.warn("[mapGlobal] cluster renderer fallback", { + mode, + count, + + photoRenderer: typeof window.createPhotoClusterIcon === "function", + + activityRenderer: typeof window.createActivityClusterIcon === "function", + }); + + return L.divIcon({ + html: ` +
+ ${count} +
+ `, + + className: "leaflet-default-cluster-wrapper", + + iconSize: L.point(48, 48), + + iconAnchor: L.point(24, 24), + }); + } + ``; + + // =============================== + // CLICK SU CLUSTER + // =============================== + function onClusterClick(event) { + if (!event?.layer || !window.globalMap) { + return; + } + + const markers = event.layer.getAllChildMarkers(); + + const items = markers.map((marker) => marker.__item).filter(Boolean); + + const count = markers.length; + + if ( + count <= OPEN_STRIP_CHILDREN_MAX || + window.globalMap.getZoom() >= DISABLE_CLUSTER_AT_ZOOM - 1 + ) { + if (items.length > 1) { + window.currentOpenCluster = event.layer; + + window.openBottomSheet?.(items); + } else if (items.length === 1) { + window.openMapItem?.(items[0]); + } + + return; + } + + window.globalMap.fitBounds(event.layer.getBounds(), { + padding: [60, 60], + maxZoom: DISABLE_CLUSTER_AT_ZOOM, + animate: true, + }); + } + + // =============================== + // APRI ELEMENTO DALLA MAPPA + // =============================== + window.openMapItem = function (item) { + if (!item) { + return; + } + + window.closeBottomSheet?.(); + + if (window.state?.mode === "photos") { + window.openPhotoMapItem?.(item); + } else { + window.openActivityModal?.(item); + } + }; + + // =============================== + // RIDISEGNA MARKER E ATTIVITÀ + // =============================== + window.redrawGlobalMap = async function () { + if (!window.globalMap || !window.globalMarkers) { + return; + } + + window.globalMarkers.clearLayers(); + + if (typeof window.clearActivityMapLayers === "function") { + window.clearActivityMapLayers(); + } + + if (window.state?.mode === "photos") { + window.hideActivityRoutesToggle?.(); + + if (typeof window.renderPhotoMapLayer === "function") { + await window.renderPhotoMapLayer(); + } + } else { + if (typeof window.renderActivityMapLayer === "function") { + await window.renderActivityMapLayer(); + } + } + }; + + // =============================== + // AGGANCIA REFRESH GALLERY + // =============================== + const originalRefresh = window.refreshGallery; + + if (typeof originalRefresh === "function") { + window.refreshGallery = function (...args) { + const result = originalRefresh.apply(this, args); + + // I dati sono cambiati: invalida l'indice spaziale + window.MapSpatialIndex?.clear?.(); + + const mapDiv = document.getElementById("globalMap"); + + if (mapDiv?.classList.contains("open")) { + Promise.resolve(window.redrawGlobalMap?.()).catch((error) => { + console.error("[mapGlobal] Errore redraw dopo refresh:", error); + }); + } + + return result; + }; + } else { + console.warn( + "[mapGlobal] refreshGallery non disponibile " + + "durante l'inizializzazione", + ); + } +}); diff --git a/public/js/map/layerManager.js b/public/js/map/layerManager.js new file mode 100644 index 0000000..01aebaa --- /dev/null +++ b/public/js/map/layerManager.js @@ -0,0 +1,63 @@ +// public/js/map/layerManager.js +// =============================== +// GESTIONE GENERICA LAYER MARKER +// =============================== + +(function () { + + "use strict"; + + + function sync(markerLayer, visibleMap, markers) { + + const nextVisible = new Map(); + + + for (const marker of markers) { + + const id = marker.__item?.id; + + if (!id) { + continue; + } + + + nextVisible.set(id, marker); + + + if (!visibleMap.has(id)) { + + markerLayer.addLayer(marker); + + } + + } + + + + for (const [id, marker] of visibleMap) { + + if (!nextVisible.has(id)) { + + markerLayer.removeLayer(marker); + + } + + } + + + return nextVisible; + + } + + + + window.MapLayerManager = { + + sync + + }; + + +})(); + diff --git a/public/js/map/mapboxMarkers.js b/public/js/map/mapboxMarkers.js new file mode 100644 index 0000000..c55d2a6 --- /dev/null +++ b/public/js/map/mapboxMarkers.js @@ -0,0 +1,960 @@ +// public/js/map/mapboxMarkers.js +// MAPBOX MARKERS CONTROLLER + +(() => { + "use strict"; + + let mapboxMap = null; + let eventsMap = null; + let idleRefreshScheduled = false; + + const state = { + photos: [], + activities: [], + routes: [], + }; + + function setMap(map) { + if (!map) { + console.error("[MapboxMarkers] mappa non valida"); + + return; + } + + if (mapboxMap === map) { + connectModules(); + + if (mapboxMap.isStyleLoaded?.()) { + initializeModules(); + } + + return; + } + + /* + * Scollega soltanto gli eventi del + * controller dalla vecchia mappa. + * + * Non richiama clearHtmlMarkers() qui, + * perché i singoli moduli potrebbero + * puntare ancora a una mappa Mapbox + * già distrutta. + */ + detachMapEvents(); + + mapboxMap = map; + + console.log("[MapboxMarkers] mappa collegata"); + + connectModules(); + setupMapEvents(); + + if (mapboxMap.isStyleLoaded?.()) { + initializeModules(); + } else { + mapboxMap.once("load", handleInitialLoad); + } + } + + function connectModules() { + if (!mapboxMap) { + return; + } + + window.MapboxPhotoClusters?.setMap?.(mapboxMap); + + window.MapboxPhotoMarkers?.setMap?.(mapboxMap); + + window.MapboxPhotoSpiderfy?.setMap?.(mapboxMap); + + window.MapboxActivityClusters?.setMap?.(mapboxMap); + + window.MapboxActivityMarkers?.setMap?.(mapboxMap); + + window.MapboxActivitySpiderfy?.setMap?.(mapboxMap); + } + + function handleInitialLoad() { + if (!mapboxMap) { + return; + } + + initializeModules(); + } + +function initializeModules() { + if (!mapboxMap?.isStyleLoaded?.()) { + return; + } + + const mode = + window.state?.mode; + + if (mode === "photos") { + window.MapboxActivityMarkers + ?.clear?.(); + + window.MapboxActivityClusters + ?.clear?.(); + + window.PhotoSource?.update?.( + mapboxMap, + state.photos, + ); + + window.MapboxPhotoClusters + ?.createLayer?.(); + + return; + } + + if (mode === "activities") { + window.MapboxPhotoMarkers + ?.clear?.(); + + window.MapboxPhotoClusters + ?.clear?.(); + + window.ActivitySource?.update?.( + mapboxMap, + state.activities, + ); + + window.MapboxActivityClusters + ?.createLayer?.(); + + updateRoutesSourceAndLayer(); + } +} + + function setupMapEvents() { + if (!mapboxMap) { + return; + } + + if (eventsMap === mapboxMap) { + return; + } + + detachMapEvents(); + + eventsMap = mapboxMap; + + eventsMap.on("style.load", handleStyleLoad); + + eventsMap.on("sourcedata", handleSourceData); + + eventsMap.on("idle", handleIdle); + } + + function detachMapEvents() { + if (!eventsMap) { + idleRefreshScheduled = false; + + return; + } + + eventsMap.off("style.load", handleStyleLoad); + + eventsMap.off("sourcedata", handleSourceData); + + eventsMap.off("idle", handleIdle); + + eventsMap.off("load", handleInitialLoad); + + eventsMap = null; + idleRefreshScheduled = false; + } + + function handleStyleLoad() { + if (!mapboxMap?.isStyleLoaded?.()) { + return; + } + + initializeModules(); + } + + function scheduleRenderedRefresh() { + if (!mapboxMap?.isStyleLoaded?.()) { + return; + } + + if (idleRefreshScheduled) { + return; + } + + idleRefreshScheduled = true; + + requestAnimationFrame(() => { + idleRefreshScheduled = false; + + if (!mapboxMap?.isStyleLoaded?.()) { + return; + } + + refreshRenderedMarkers(); + }); + } + +function ensureMapInfrastructure() { + if (!mapboxMap?.isStyleLoaded?.()) { + return; + } + + const mode = + window.state?.mode; + + if (mode === "photos") { + if ( + state.photos.length > 0 && + !mapboxMap.getSource( + "photos", + ) + ) { + console.log( + "[MapboxMarkers] creazione source photos", + ); + + window.PhotoSource?.create?.( + mapboxMap, + state.photos, + ); + } + + if ( + mapboxMap.getSource( + "photos", + ) && + !mapboxMap.getLayer( + "photos-clusters", + ) + ) { + console.log( + "[MapboxMarkers] creazione layer photos", + ); + + window.MapboxPhotoClusters + ?.createLayer?.(); + } + + return; + } + + if (mode === "activities") { +if (mode === "activities") { + window.ActivitySource?.update?.( + mapboxMap, + state.activities, + ); + + if ( + mapboxMap.getSource( + "activities", + ) && + !mapboxMap.getLayer( + "activities-clusters", + ) + ) { + console.log( + "[MapboxMarkers] creazione layer activities", + ); + + window.MapboxActivityClusters + ?.createLayer?.(); + } +} + + if ( + mapboxMap.getSource( + "activities", + ) && + !mapboxMap.getLayer( + "activities-clusters", + ) + ) { + console.log( + "[MapboxMarkers] creazione layer activities", + ); + + window.MapboxActivityClusters + ?.createLayer?.(); + } + } +} + + function handleIdle() { + if (!mapboxMap?.isStyleLoaded?.()) { + return; + } + + if (idleRefreshScheduled) { + return; + } + + idleRefreshScheduled = true; + + requestAnimationFrame(() => { + idleRefreshScheduled = false; + + if (!mapboxMap?.isStyleLoaded?.()) { + return; + } + + ensureMapInfrastructure(); + refreshRenderedMarkers(); + }); + } + + function handleSourceData(event) { + if (!mapboxMap?.isStyleLoaded?.()) { + return; + } + + if (event.dataType !== "source") { + return; + } + + const sourceId = event.sourceId; + +const mode = + window.state?.mode; + +if ( + mode === "photos" && + sourceId !== "photos" +) { + return; +} + +if ( + mode === "activities" && + sourceId !== "activities" +) { + return; +} + + if (sourceId !== "photos" && sourceId !== "activities") { + return; + } + + /* + * Aspettiamo che Mapbox abbia terminato + * di caricare e indicizzare la source. + */ + const sourceLoaded = + typeof mapboxMap.isSourceLoaded === "function" + ? mapboxMap.isSourceLoaded(sourceId) + : event.isSourceLoaded === true; + + if (!sourceLoaded) { + return; + } + + scheduleRenderedRefresh(); + } + +function refreshRenderedMarkers() { + if (!mapboxMap?.isStyleLoaded?.()) { + return; + } + + const mode = + window.state?.mode; + + if (mode === "photos") { + window.MapboxActivityMarkers + ?.clear?.(); + + window.MapboxActivityClusters + ?.clear?.(); + + if ( + mapboxMap.getSource( + "photos", + ) && + !window.MapboxPhotoSpiderfy + ?.isOpen?.() + ) { + window.MapboxPhotoClusters + ?.update?.(); + + window.MapboxPhotoMarkers + ?.update?.( + state.photos, + ); + } + + return; + } + + if (mode === "activities") { + window.MapboxPhotoMarkers + ?.clear?.(); + + window.MapboxPhotoClusters + ?.clear?.(); + + if ( + mapboxMap.getSource( + "activities", + ) && + !window.MapboxActivitySpiderfy + ?.isOpen?.() + ) { + window.MapboxActivityClusters + ?.update?.(); + + window.MapboxActivityMarkers + ?.update?.( + state.activities, + ); + } + } +} + +function refreshPhotos() { + if ( + window.state?.mode !== + "photos" + ) { + return; + } + + if (!mapboxMap) { + return; + } + + if (!mapboxMap.isStyleLoaded?.()) { + return; + } + + if ( + window.MapboxPhotoSpiderfy + ?.isOpen?.() + ) { + return; + } + + if ( + !mapboxMap.getSource( + "photos", + ) + ) { + return; + } + + window.MapboxPhotoClusters + ?.update?.(); + + window.MapboxPhotoMarkers + ?.update?.( + state.photos, + ); +} + +function refreshActivities() { + if ( + window.state?.mode !== + "activities" + ) { + return; + } + + if (!mapboxMap?.isStyleLoaded?.()) { + return; + } + + if ( + window.MapboxActivitySpiderfy + ?.isOpen?.() + ) { + return; + } + + if ( + !mapboxMap.getSource( + "activities", + ) + ) { + return; + } + + window.MapboxActivityClusters + ?.update?.(); + + window.MapboxActivityMarkers + ?.update?.( + state.activities, + ); +} + + function refreshRoutes() { + updateRoutesSourceAndLayer(); + } + + function refreshAll() { + if (!mapboxMap?.isStyleLoaded?.()) { + return; + } + + initializeModules(); + } + +function setPhotos(features = []) { + replaceStateArray( + state.photos, + features, + ); + + const photosMode = + window.state?.mode === + "photos"; + + console.log( + "[MapboxMarkers] photos", + { + features: + state.photos.length, + + photosMode, + + styleLoaded: + !!mapboxMap + ?.isStyleLoaded?.(), + + PhotoSource: + !!window.PhotoSource, + + PhotoClusters: + !!window + .MapboxPhotoClusters, + }, + ); + + if (!mapboxMap) { + return; + } + + if ( + !mapboxMap + .isStyleLoaded?.() + ) { + return; + } + + if (!window.PhotoSource) { + console.warn( + "[MapboxMarkers] PhotoSource non disponibile", + ); + + return; + } + + if (!photosMode) { + window.MapboxPhotoMarkers + ?.clear?.(); + + window.MapboxPhotoClusters + ?.clear?.(); + + const existingSource = + mapboxMap.getSource?.( + "photos", + ); + + if (existingSource) { + window.PhotoSource.update( + mapboxMap, + [], + ); + } + + return; + } + + window.PhotoSource.update( + mapboxMap, + state.photos, + ); + + const sourceExists = + !!mapboxMap.getSource?.( + "photos", + ); + + console.log( + "[MapboxMarkers] photos source", + { + exists: + sourceExists, + + features: + state.photos.length, + }, + ); + + if (!sourceExists) { + console.warn( + "[MapboxMarkers] source photos non creata", + ); + + return; + } + + window.MapboxPhotoClusters + ?.createLayer?.(); + + console.log( + "[MapboxMarkers] photos layers", + { + clusters: + !!mapboxMap.getLayer?.( + "photos-clusters", + ), + + count: + !!mapboxMap.getLayer?.( + "photos-cluster-count", + ), + }, + ); + + scheduleRenderedRefresh(); +} + + +function setActivities(features = []) { + replaceStateArray( + state.activities, + features, + ); + + const activitiesMode = + window.state?.mode === + "activities"; + + console.log( + "[MapboxMarkers] activities", + { + features: + state.activities.length, + + activitiesMode, + + styleLoaded: + !!mapboxMap + ?.isStyleLoaded?.(), + }, + ); + + if (!mapboxMap) { + return; + } + + if ( + !mapboxMap.isStyleLoaded?.() + ) { + return; + } + + if (!window.ActivitySource) { + console.warn( + "[MapboxMarkers] ActivitySource non disponibile", + ); + + return; + } + + if (!activitiesMode) { + window.MapboxActivityMarkers + ?.clear?.(); + + window.MapboxActivityClusters + ?.clear?.(); + + if ( + mapboxMap.getSource?.( + "activities", + ) + ) { + window.ActivitySource.update( + mapboxMap, + [], + ); + } + + return; + } + + window.ActivitySource.update( + mapboxMap, + state.activities, + ); + + if ( + !mapboxMap.getSource?.( + "activities", + ) + ) { + console.warn( + "[MapboxMarkers] source activities non creata", + ); + + return; + } + + window.MapboxActivityClusters + ?.createLayer?.(); + + scheduleRenderedRefresh(); +} + +function setRoutes(features = []) { + replaceStateArray( + state.routes, + features, + ); + + console.log( + "[MapboxMarkers] routes", + state.routes.length, + ); + + if ( + window.state?.mode !== + "activities" + ) { + return; + } + + if ( + !mapboxMap || + !mapboxMap + .isStyleLoaded?.() + ) { + return; + } + + updateRoutesSourceAndLayer(); +} + + function replaceStateArray(target, features) { + target.length = 0; + + if (Array.isArray(features)) { + target.push(...features); + } + } + + function updateRoutesSourceAndLayer() { + if (!mapboxMap?.isStyleLoaded?.()) { + return; + } + + const data = { + type: "FeatureCollection", + features: state.routes, + }; + + const source = mapboxMap.getSource("routes"); + + if (source) { + source.setData(data); + } else { + mapboxMap.addSource("routes", { + type: "geojson", + data, + }); + } + + if (!mapboxMap.getLayer("routes-line")) { + mapboxMap.addLayer({ + id: "routes-line", + + type: "line", + + source: "routes", + + paint: { + "line-color": "#ff6b00", + "line-width": 4, + "line-opacity": 0.8, + }, + }); + } + } + + function clearHtmlMarkers() { + if (window.MapboxPhotoSpiderfy?.isOpen?.()) { + window.MapboxPhotoSpiderfy?.clear?.(); + } + + window.MapboxPhotoMarkers?.clear?.(); + + window.MapboxPhotoClusters?.clear?.(); + + if (window.MapboxActivitySpiderfy?.isOpen?.()) { + window.MapboxActivitySpiderfy?.clear?.(); + } + + window.MapboxActivityMarkers?.clear?.(); + + window.MapboxActivityClusters?.clear?.(); + } + + function clearState() { + // Svuota lo stato interno. + state.photos.length = 0; + state.activities.length = 0; + state.routes.length = 0; + + // Rimuove marker, cluster e spiderfy HTML. + clearHtmlMarkers(); + + // La mappa potrebbe non esistere più. + if (!mapboxMap) { + return; + } + + // Evita chiamate Mapbox quando lo stile + // è stato rimosso o non è disponibile. + let styleLoaded = false; + + try { + styleLoaded = !!mapboxMap.isStyleLoaded?.(); + } catch (error) { + console.warn( + "[MapboxMarkers] mappa non più disponibile durante clearState", + error, + ); + + return; + } + + if (!styleLoaded) { + return; + } + + // Svuota la source Photos. + try { + if (mapboxMap.getSource?.("photos")) { + window.PhotoSource?.update?.(mapboxMap, []); + } + } catch (error) { + console.warn("[MapboxMarkers] errore pulizia source photos", error); + } + + // Svuota la source Activities. + try { + if (mapboxMap.getSource?.("activities")) { + window.ActivitySource?.update?.(mapboxMap, []); + } + } catch (error) { + console.warn("[MapboxMarkers] errore pulizia source activities", error); + } + + // Svuota la source Routes. + try { + const routesSource = mapboxMap.getSource?.("routes"); + + routesSource?.setData?.({ + type: "FeatureCollection", + + features: [], + }); + } catch (error) { + console.warn("[MapboxMarkers] errore pulizia source routes", error); + } + } + + function getPhotos() { + return state.photos; + } + + function getActivities() { + return state.activities; + } + + function getRoutes() { + return state.routes; + } + + function getMap() { + return mapboxMap; + } + + function debug() { + const result = { + map: !!mapboxMap, + + styleLoaded: !!mapboxMap?.isStyleLoaded?.(), + + eventsConnected: eventsMap === mapboxMap, + + idleRefreshScheduled, + + photos: state.photos.length, + + activities: state.activities.length, + + routes: state.routes.length, + + photoSource: !!mapboxMap?.getSource?.("photos"), + + photoClustersLayer: !!mapboxMap?.getLayer?.("photos-clusters"), + + photoCountLayer: !!mapboxMap?.getLayer?.("photos-cluster-count"), + + photoMarkers: window.MapboxPhotoMarkers?.size?.() ?? 0, + + photoClusterMarkers: window.MapboxPhotoClusters?.size?.() ?? 0, + + activitySource: !!mapboxMap?.getSource?.("activities"), + + activityClustersLayer: !!mapboxMap?.getLayer?.("activities-clusters"), + + activityCountLayer: !!mapboxMap?.getLayer?.("activities-cluster-count"), + + activityMarkers: window.MapboxActivityMarkers?.size?.() ?? 0, + + activityClusterMarkers: window.MapboxActivityClusters?.size?.() ?? 0, + + activitySpiderfyOpen: window.MapboxActivitySpiderfy?.isOpen?.() ?? false, + + activitySpiderMarkers: window.MapboxActivitySpiderfy?.size?.() ?? 0, + + routesSource: !!mapboxMap?.getSource?.("routes"), + + routesLayer: !!mapboxMap?.getLayer?.("routes-line"), + }; + + console.log("[MapboxMarkers DEBUG]", result); + + return result; + } + + window.debugMapboxMarkers = debug; + + window.MapboxMarkers = { + setMap, + getMap, + + setPhotos, + setActivities, + setRoutes, + + getPhotos, + getActivities, + getRoutes, + + refreshAll, + refreshPhotos, + refreshActivities, + refreshRoutes, + + clearMarkers: clearHtmlMarkers, + + clearState, + debug, + }; +})(); diff --git a/public/js/map/markerFactory.js b/public/js/map/markerFactory.js new file mode 100644 index 0000000..8c825c6 --- /dev/null +++ b/public/js/map/markerFactory.js @@ -0,0 +1,135 @@ +// public/js/map/markerFactory.js +// =============================== +// FACTORY MARKER FOTO +// =============================== + +(function () { + "use strict"; + + const photoMarkerCache = new Map(); + + function createPhotoIcon(photo) { + const thumb = toAbsoluteUrl( + photo.thub1 || photo.thub2 || photo.path, + photo.name, + "thumbs", + photo.cartella, + ); + + return L.icon({ + iconUrl: thumb, + iconSize: [46, 46], + iconAnchor: [23, 23], + className: "photo-marker", + }); + } + + function getPhotoMarker(photo) { + let marker = photoMarkerCache.get(photo.id); + + if (marker) { + return marker; + } + + const lat = Number(photo.gps.lat); + const lng = Number(photo.gps.lng); + + marker = L.marker([lat, lng], { + icon: createPhotoIcon(photo), + title: photo.display_name || photo.name || "", + }); + + marker.__item = photo; + + marker.__thumb = toAbsoluteUrl( + photo.thub1 || photo.thub2 || photo.path, + photo.name, + "thumbs", + photo.cartella, + ); + + marker.on("click", () => { + window.openPhotoMapItem(photo); + }); + + photoMarkerCache.set(photo.id, marker); + + return marker; + } + +window.PhotoMarkerFactory = { + get(photo) { + return getPhotoMarker(photo); + }, + + createIcon(photo) { + return createPhotoIcon(photo); + }, + + createIndependent(photo) { + const lat = + Number(photo?.gps?.lat); + + const lng = + Number(photo?.gps?.lng); + + if ( + !Number.isFinite(lat) || + !Number.isFinite(lng) + ) { + return null; + } + + const marker = + L.marker( + [lat, lng], + { + icon: + createPhotoIcon( + photo, + ), + + title: + photo.display_name || + photo.name || + "", + }, + ); + + marker.__item = + photo; + + marker.__thumb = + toAbsoluteUrl( + photo.thub1 || + photo.thub2 || + photo.path, + + photo.name, + "thumbs", + photo.cartella, + ); + + marker.on( + "click", + () => { + window.openPhotoMapItem?.( + photo, + ); + }, + ); + + return marker; + }, + + clear() { + photoMarkerCache.clear(); + }, + + size() { + return photoMarkerCache.size; + }, +}; + +})(); + diff --git a/public/js/map/photos/photoClusters.js b/public/js/map/photos/photoClusters.js new file mode 100644 index 0000000..e78dacd --- /dev/null +++ b/public/js/map/photos/photoClusters.js @@ -0,0 +1,664 @@ +// public/js/map/photos/photoClusters.js +// =================================== +// MAPBOX PHOTO CLUSTERS +// =================================== + +(() => { + "use strict"; + + let mapboxMap = null; + + const clusterMarkers = new Map(); + + let renderToken = 0; + + const SOURCE_ID = "photos"; + const CLUSTER_LAYER_ID = "photos-clusters"; + const COUNT_LAYER_ID = "photos-cluster-count"; + + // =================================== + // SET MAP + // =================================== + + function setMap(map) { + mapboxMap = map; + + console.log("[PhotoClusters] setMap"); + } + + // =================================== + // CREA LAYER CLUSTER + // =================================== + + function createLayer() { + + + //console.group("[PhotoClusters] createLayer CALL"); + + + + + + if (!mapboxMap) { + return; + } + + if (!mapboxMap.isStyleLoaded()) { + return; + } + + if (!mapboxMap.getSource(SOURCE_ID)) { + console.warn( + "[PhotoClusters] source photos non presente", + ); + + return; + } + + // --------------------------------- + // LAYER CLUSTER + // --------------------------------- + + if (!mapboxMap.getLayer(CLUSTER_LAYER_ID)) { + console.log( + "[PhotoClusters] creo layer cluster", + ); + + mapboxMap.addLayer({ + id: CLUSTER_LAYER_ID, + + type: "circle", + + source: SOURCE_ID, + + filter: ["has", "point_count"], + + paint: { + /* + * Il layer Mapbox deve rimanere + * renderizzato perché viene utilizzato + * da queryRenderedFeatures(). + * + * Il pallino Mapbox viene però nascosto. + */ + + "circle-color": "#3388ff", + + "circle-radius": [ + "step", + ["get", "point_count"], + 22, + 10, + 28, + 50, + 38, + ], + + "circle-stroke-color": "#ffffff", + + "circle-stroke-width": 3, + + "circle-opacity": 0, + + "circle-stroke-opacity": 0, + }, + }); + } + + // --------------------------------- + // NUMERO CLUSTER + // --------------------------------- + + if (!mapboxMap.getLayer(COUNT_LAYER_ID)) { + mapboxMap.addLayer({ + id: COUNT_LAYER_ID, + + type: "symbol", + + source: SOURCE_ID, + + filter: ["has", "point_count"], + + layout: { + "text-field": "{point_count_abbreviated}", + + "text-size": 14, + }, + + paint: { + /* + * Anche il testo originale Mapbox + * viene nascosto perché il numero + * viene mostrato dal marker HTML. + */ + + "text-color": "#ffffff", + + "text-opacity": 0, + }, + }); + } + + console.log( + "[PhotoClusters] layer creati", + ); + } + + // =================================== + // GET RENDERED CLUSTERS + // =================================== + + function getRenderedClusters() { + if (!mapboxMap) { + return []; + } + + if (!mapboxMap.getLayer(CLUSTER_LAYER_ID)) { + return []; + } + + const rendered = + mapboxMap.queryRenderedFeatures({ + layers: [CLUSTER_LAYER_ID], + }); + + const clusters = []; + + const seen = new Set(); + + for (const feature of rendered) { + const clusterId = + feature.properties?.cluster_id; + + if ( + clusterId === undefined || + clusterId === null + ) { + continue; + } + + const id = String(clusterId); + + if (seen.has(id)) { + continue; + } + + seen.add(id); + + clusters.push(feature); + } + + return clusters; + } + + // =================================== + // CREA ELEMENTO HTML + // =================================== + + function createClusterElement( + cluster, + thumbs, + ) { + const count = + Number( + cluster.properties?.point_count, + ) || 0; + + const size = Math.min( + 92, + Math.round( + 28 + Math.sqrt(count) * 6, + ), + ); + + const cls = + count > 200 + ? "cluster-xl" + : count > 50 + ? "cluster-lg" + : count > 10 + ? "cluster-md" + : "cluster-sm"; + + const el = + document.createElement("div"); + + el.className = + "marker-cluster-wrapper mapbox-cluster-wrapper"; + + el.style.width = `${size}px`; + el.style.height = `${size}px`; + + // ================================= + // COLLAGE + // ================================= + + const collage = thumbs.length + ? ` +
+ ${thumbs + .map( + (src, index) => ` +
+ +
+ `, + ) + .join("")} +
+ ` + : ""; + + // ================================= + // HTML + // ================================= + + el.innerHTML = ` +
+ ${collage} + +
+ ${count} +
+
+ `; + + return el; + } + + // =================================== + // CLICK CLUSTER + // =================================== + + function handleClusterClick( + clusterId, + coordinates, + ) { + console.log( + "[PhotoClusters] click cluster", + { + clusterId, + coordinates, + }, + ); + + if ( + window.MapboxPhotoSpiderfy + ?.handleClusterClick + ) { + window.MapboxPhotoSpiderfy.handleClusterClick( + clusterId, + coordinates, + ); + + return; + } + + console.warn( + "[PhotoClusters] PhotoSpiderfy non disponibile", + ); + } + + // =================================== + // CREA MARKER HTML + // =================================== + + function createClusterMarker( + cluster, + thumbs, + ) { + if (!mapboxMap) { + return null; + } + + const coordinates = + cluster.geometry?.coordinates; + + if ( + !coordinates || + coordinates.length < 2 + ) { + return null; + } + + const clusterId = String( + cluster.properties?.cluster_id, + ); + + const el = + createClusterElement( + cluster, + thumbs, + ); + + // --------------------------------- + // CLICK + // --------------------------------- + + el.addEventListener( + "click", + (event) => { + event.stopPropagation(); + + handleClusterClick( + Number(clusterId), + coordinates, + ); + }, + ); + + // --------------------------------- + // MARKER MAPBOX + // --------------------------------- + + return new mapboxgl.Marker({ + element: el, + anchor: "center", + }) + .setLngLat(coordinates) + .addTo(mapboxMap); + } + + // =================================== + // RECUPERA THUMBNAILS + // =================================== + + function getClusterThumbs( + clusterId, + callback, + ) { + if (!mapboxMap) { + return; + } + + const source = + mapboxMap.getSource(SOURCE_ID); + + if (!source) { + return; + } + + source.getClusterLeaves( + Number(clusterId), + 4, + 0, + callback, + ); + } + + // =================================== + // UPDATE + // =================================== + + function update() { + if (!mapboxMap) { + return; + } + + if (!mapboxMap.isStyleLoaded()) { + return; + } + + if (!mapboxMap.getSource(SOURCE_ID)) { + return; + } + + //createLayer(); + + if ( + window.MapboxPhotoSpiderfy + ?.isOpen?.() + ) { + return; + } + + const token = ++renderToken; + + const clusters = + getRenderedClusters(); + + const currentIds = new Set( + clusters.map((cluster) => + String( + cluster.properties?.cluster_id, + ), + ), + ); + + console.log( + "[PhotoClusters] rendered:", + clusters.length, + ); + + // ================================= + // CREA / AGGIORNA + // ================================= + + for (const cluster of clusters) { + const clusterId = String( + cluster.properties?.cluster_id, + ); + + const coordinates = + cluster.geometry?.coordinates; + + if ( + !coordinates || + coordinates.length < 2 + ) { + continue; + } + + // -------------------------------- + // MARKER GIÀ ESISTENTE + // -------------------------------- + + if ( + clusterMarkers.has(clusterId) + ) { + const marker = + clusterMarkers.get(clusterId); + + marker.setLngLat( + coordinates, + ); + + marker + .getElement() + .style.display = ""; + + continue; + } + + // -------------------------------- + // RECUPERA THUMB + // -------------------------------- + + getClusterThumbs( + clusterId, + (err, leaves) => { + if (err) { + console.error( + "[PhotoClusters] leaves error", + err, + ); + + return; + } + + // -------------------------------- + // TOKEN + // -------------------------------- + + if ( + token !== renderToken + ) { + return; + } + + // -------------------------------- + // CLUSTER ANCORA VISIBILE + // -------------------------------- + + if ( + !currentIds.has( + clusterId, + ) + ) { + return; + } + + const thumbs = + leaves + .map( + (feature) => + feature.properties?.thumb, + ) + .filter(Boolean); + + const marker = + createClusterMarker( + cluster, + thumbs, + ); + + if (!marker) { + return; + } + + clusterMarkers.set( + clusterId, + marker, + ); + }, + ); + } + + // ================================= + // RIMUOVI NON VISIBILI + // ================================= + + for ( + const [ + clusterId, + marker, + ] of clusterMarkers + ) { + if ( + !currentIds.has( + String(clusterId), + ) + ) { + marker.remove(); + + clusterMarkers.delete( + clusterId, + ); + } + } + } + + // =================================== + // HIDE + // =================================== + + function hide() { + for ( + const marker of clusterMarkers.values() + ) { + marker + .getElement() + .style.display = "none"; + } + } + + // =================================== + // SHOW + // =================================== + + function show() { + for ( + const marker of clusterMarkers.values() + ) { + marker + .getElement() + .style.display = ""; + } + } + + // =================================== + // CLEAR + // =================================== + + function clear() { + renderToken++; + + for ( + const marker of clusterMarkers.values() + ) { + marker.remove(); + } + + clusterMarkers.clear(); + } + + // =================================== + // SIZE + // =================================== + + function size() { + return clusterMarkers.size; + } + + // =================================== + // DEBUG + // =================================== + + function debug() { + console.log( + "[PhotoClusters] debug", + { + markers: + clusterMarkers.size, + + rendered: + getRenderedClusters().length, + + layer: + !!mapboxMap?.getLayer( + CLUSTER_LAYER_ID, + ), + + source: + !!mapboxMap?.getSource( + SOURCE_ID, + ), + }, + ); + } + + // =================================== + // PUBLIC API + // =================================== + + window.MapboxPhotoClusters = { + setMap, + createLayer, + update, + hide, + show, + clear, + size, + debug, + getRenderedClusters, + }; +})(); diff --git a/public/js/map/photos/photoMarkers.js b/public/js/map/photos/photoMarkers.js new file mode 100644 index 0000000..0238377 --- /dev/null +++ b/public/js/map/photos/photoMarkers.js @@ -0,0 +1,405 @@ +// public/js/map/photos/photoMarkers.js +// =================================== +// MAPBOX PHOTO MARKERS +// =================================== + +(() => { + "use strict"; + + let mapboxMap = null; + + const markers = new Map(); + + const SOURCE_ID = "photos"; + + // =================================== + // SET MAP + // =================================== + + function setMap(map) { + mapboxMap = map; + + console.log("[PhotoMarkers] setMap"); + } + + // =================================== + // CREA ELEMENTO FOTO + // =================================== + + function createPhotoElement(feature) { + const img = document.createElement("img"); + + img.src = feature.properties?.thumb || ""; + + img.width = 46; + img.height = 46; + + img.style.borderRadius = "7px"; + img.style.objectFit = "cover"; + img.style.border = "2px solid white"; + img.style.cursor = "pointer"; + + return img; + } + + // =================================== + // CLICK FOTO + // =================================== + + function handlePhotoClick(feature) { + if (!feature) { + return; + } + + const id = String( + feature.properties?.id, + ); + + // ================================= + // FOTO VICINE + // ================================= + + if ( + window.MapboxPhotoSpiderfy + ?.getNearbyPhotos + ) { + const nearbyPhotos = + window.MapboxPhotoSpiderfy.getNearbyPhotos( + feature, + 3, + ); + + console.log( + "[PhotoMarkers] foto entro 3 metri:", + nearbyPhotos.length, + ); + + if (nearbyPhotos.length > 1) { + window.MapboxPhotoSpiderfy.spiderfyPhotos( + nearbyPhotos, + ); + + return; + } + } + + // ================================= + // APERTURA FOTO + // ================================= + + const item = + window.getMapPhotoItem?.(id) || + ( + typeof window.getLocalItems === "function" + ? window.getLocalItems().find( + photo => String(photo.id) === id, + ) + : null + ); + + if (item) { + window.openPhotoMapItem?.(item); + } + + // ================================= + // CHIUDI EVENTUALE SPIDERFY + // ================================= + + window.MapboxPhotoSpiderfy + ?.clear?.(); + } + + // =================================== + // CREA MARKER + // =================================== + + function createMarker(feature) { + if (!mapboxMap) { + return null; + } + + const id = String( + feature.properties?.id, + ); + + // Evita duplicati. + if (markers.has(id)) { + return markers.get(id); + } + + const img = + createPhotoElement(feature); + + img.addEventListener( + "click", + (event) => { + event.stopPropagation(); + + handlePhotoClick(feature); + }, + ); + + const marker = + new mapboxgl.Marker({ + element: img, + }) + .setLngLat( + feature.geometry.coordinates, + ) + .addTo(mapboxMap); + + marker.__originalLngLat = + feature.geometry.coordinates; + + marker.__photoId = id; + + /* + * Questo marker appartiene + * esclusivamente ai marker normali. + * + * Gli spider marker sono gestiti + * da photoSpiderfy.js. + */ + marker.__spider = false; + + markers.set(id, marker); + + return marker; + } + + // =================================== + // FOTO VISIBILI + // =================================== + + function getVisibleIds() { + if (!mapboxMap) { + return new Set(); + } + + if ( + !mapboxMap.getSource( + SOURCE_ID, + ) + ) { + return new Set(); + } + + const sourcePoints = + mapboxMap.querySourceFeatures( + SOURCE_ID, + { + filter: [ + "!", + ["has", "point_count"], + ], + }, + ); + + return new Set( + sourcePoints.map( + (feature) => + String( + feature.properties?.id, + ), + ), + ); + } + + // =================================== + // UPDATE + // =================================== + + function update(features = []) { + if (!mapboxMap) { + return; + } + + if (!mapboxMap.isStyleLoaded()) { + return; + } + + const visibleIds = + getVisibleIds(); + + console.log( + "[PhotoMarkers] visibility", + { + features: features.length, + visible: + visibleIds.size, + }, + ); + + // ================================= + // RIMUOVI FOTO CLUSTERIZZATE + // ================================= + + for ( + const [ + id, + marker, + ] of markers + ) { + /* + * Se una foto non è più un punto + * individuale della source, significa + * che è stata assorbita da un cluster. + */ + if ( + !visibleIds.has(id) + ) { + marker.remove(); + + markers.delete(id); + } + } + + // ================================= + // CREA / AGGIORNA FOTO + // ================================= + + for (const feature of features) { + const id = String( + feature.properties?.id, + ); + + // -------------------------------- + // FOTO DENTRO UN CLUSTER + // -------------------------------- + + if ( + !visibleIds.has(id) + ) { + continue; + } + + // -------------------------------- + // MARKER ESISTENTE + // -------------------------------- + + if ( + markers.has(id) + ) { + const marker = + markers.get(id); + + marker.setLngLat( + feature.geometry.coordinates, + ); + + marker.__originalLngLat = + feature.geometry.coordinates; + + continue; + } + + // -------------------------------- + // CREA MARKER + // -------------------------------- + + createMarker(feature); + } + } + + // =================================== + // GET MARKER + // =================================== + + function get(id) { + return markers.get( + String(id), + ); + } + + // =================================== + // GET ALL + // =================================== + + function getAll() { + return markers; + } + + // =================================== + // REMOVE + // =================================== + + function remove(id) { + const key = String(id); + + const marker = + markers.get(key); + + if (!marker) { + return; + } + + marker.remove(); + + markers.delete(key); + } + + // =================================== + // CLEAR + // =================================== + + function clear() { + for ( + const marker of markers.values() + ) { + marker.remove(); + } + + markers.clear(); + } + + // =================================== + // SIZE + // =================================== + + function size() { + return markers.size; + } + + // =================================== + // DEBUG + // =================================== + + function debug() { + console.log( + "[PhotoMarkers] debug", + { + markers: + markers.size, + + ids: + [...markers.keys()], + }, + ); + } + + // =================================== + // PUBLIC API + // =================================== + + window.MapboxPhotoMarkers = { + setMap, + + update, + + createMarker, + + getVisibleIds, + + get, + + getAll, + + remove, + + clear, + + size, + + debug, + }; +})(); diff --git a/public/js/map/photos/photoSource.js b/public/js/map/photos/photoSource.js new file mode 100644 index 0000000..e11b97a --- /dev/null +++ b/public/js/map/photos/photoSource.js @@ -0,0 +1,126 @@ +// public/js/map/photos/photoSource.js +// =================================== +// PHOTO SOURCE +// =================================== + +(() => { + "use strict"; + + const SOURCE_ID = "photos"; + const CLUSTER_RADIUS = 80; + + // =============================== + // CREATE / UPDATE SOURCE + // =============================== + + function createPhotoSource(map, features = []) { + if (!map) { + return null; + } + + if (!map.isStyleLoaded()) { + return null; + } + + const existingSource = map.getSource(SOURCE_ID); + + if (existingSource) { + updatePhotoSource(map, features); + return existingSource; + } + + const data = { + type: "FeatureCollection", + features, + }; + + map.addSource(SOURCE_ID, { + type: "geojson", + data, + cluster: true, + clusterRadius: CLUSTER_RADIUS, + }); + + return map.getSource(SOURCE_ID); + } + + function updatePhotoSource(map, features = []) { + if (!map) { + return null; + } + + if (!map.isStyleLoaded()) { + return null; + } + + const data = { + type: "FeatureCollection", + features, + }; + + const source = map.getSource(SOURCE_ID); + + if (source) { + source.setData(data); + return source; + } + + return createPhotoSource(map, features); + } + + // =============================== + // GET SOURCE + // =============================== + + function getPhotoSource(map) { + if (!map) { + return null; + } + + return map.getSource(SOURCE_ID) || null; + } + + // =============================== + // SOURCE FEATURES + // =============================== + + function getPhotoSourceClusters(map) { + if (!map) { + return []; + } + + if (!map.getSource(SOURCE_ID)) { + return []; + } + + return map.querySourceFeatures(SOURCE_ID, { + filter: ["has", "point_count"], + }); + } + + function getPhotoSourcePoints(map) { + if (!map) { + return []; + } + + if (!map.getSource(SOURCE_ID)) { + return []; + } + + return map.querySourceFeatures(SOURCE_ID, { + filter: ["!", ["has", "point_count"]], + }); + } + + // =============================== + // PUBLIC API + // =============================== + + window.PhotoSource = { + create: createPhotoSource, + update: updatePhotoSource, + get: getPhotoSource, + getClusters: getPhotoSourceClusters, + getPoints: getPhotoSourcePoints, + }; +})(); diff --git a/public/js/map/photos/photoSpiderfy.js b/public/js/map/photos/photoSpiderfy.js new file mode 100644 index 0000000..56a60da --- /dev/null +++ b/public/js/map/photos/photoSpiderfy.js @@ -0,0 +1,863 @@ +// public/js/map/photos/photoSpiderfy.js +// =================================== +// MAPBOX PHOTO SPIDERFY +// =================================== + +(() => { + "use strict"; + + let mapboxMap = null; + + const spiderMarkers = []; + + let spiderfyOpen = false; + + const SOURCE_ID = "photos"; + + // =================================== + // SET MAP + // =================================== + + function setMap(map) { + mapboxMap = map; + + console.log("[PhotoSpiderfy] setMap"); + + // --------------------------------- + // ZOOM + // --------------------------------- + + mapboxMap.on("zoomstart", () => { + if (!spiderfyOpen) { + return; + } + + console.log( + "[PhotoSpiderfy] zoom iniziato → chiudo", + ); + + clear(); + }); + + // --------------------------------- + // MOVEEND + // --------------------------------- + + mapboxMap.on("moveend", () => { + if (spiderfyOpen) { + return; + } + + if ( + window.MapboxPhotoClusters + ) { + window.MapboxPhotoClusters.update(); + } + + if ( + window.MapboxPhotoMarkers + ) { + const photos = + getAllPhotos(); + + window.MapboxPhotoMarkers.update( + photos, + ); + } + }); + + // --------------------------------- + // CLICK FUORI + // --------------------------------- + + mapboxMap.on("click", (event) => { + if (!spiderfyOpen) { + return; + } + + const hits = + mapboxMap.queryRenderedFeatures( + event.point, + { + layers: [ + "photos-clusters", + ], + }, + ); + + if (hits.length) { + return; + } + + clear(); + }); + } + + // =================================== + // GET TUTTE LE FOTO + // =================================== + + function getAllPhotos() { + if (!mapboxMap) { + return []; + } + + if ( + !mapboxMap.getSource( + SOURCE_ID, + ) + ) { + return []; + } + + return mapboxMap.querySourceFeatures( + SOURCE_ID, + { + filter: [ + "!", + ["has", "point_count"], + ], + }, + ); + } + + // =================================== + // DISTANZA + // =================================== + + function distanceMeters(a, b) { + const R = 6371000; + + const lat1 = + (a[1] * Math.PI) / 180; + + const lat2 = + (b[1] * Math.PI) / 180; + + const dLat = + lat2 - lat1; + + const dLng = + ((b[0] - a[0]) * Math.PI) / + 180; + + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1) * + Math.cos(lat2) * + Math.sin(dLng / 2) ** 2; + + return ( + 2 * + R * + Math.atan2( + Math.sqrt(h), + Math.sqrt(1 - h), + ) + ); + } + + // =================================== + // FOTO VICINE + // =================================== + + function getNearbyPhotos( + feature, + maxDistance = 3, + ) { + if (!feature) { + return []; + } + + const center = + feature.geometry.coordinates; + + /* + * Preferiamo i dati completi + * mantenuti dal source/orchestratore. + */ + let photos = []; + + if ( + window.MapboxMarkers + ?.getPhotos + ) { + photos = + window.MapboxMarkers.getPhotos(); + } + + /* + * Fallback: + * leggiamo direttamente + * le feature dalla source. + */ + if (!photos.length) { + photos = getAllPhotos(); + } + + return photos.filter( + (other) => + distanceMeters( + center, + other.geometry.coordinates, + ) <= maxDistance, + ); + } + + // =================================== + // HIDE CLUSTER HTML + // =================================== + + function hideClusterMarkers() { + if ( + !window.MapboxPhotoClusters + ) { + return; + } + + window.MapboxPhotoClusters.hide(); + } + + // =================================== + // SHOW CLUSTER HTML + // =================================== + + function showClusterMarkers() { + if ( + !window.MapboxPhotoClusters + ) { + return; + } + + window.MapboxPhotoClusters.show(); + } + + // =================================== + // CLEAR SPIDERFY + // =================================== + + function clear() { + console.log( + "[PhotoSpiderfy] chiusura", + ); + + // --------------------------------- + // RIMUOVI MARKER SPIDER + // --------------------------------- + + for ( + const marker of spiderMarkers + ) { + marker.remove(); + + const id = + marker.__photoId; + + if ( + id !== undefined && + window.MapboxPhotoMarkers + ) { + /* + * Rimuoviamo solo il marker + * spider dal modulo marker. + */ + window.MapboxPhotoMarkers.remove( + id, + ); + } + } + + spiderMarkers.length = 0; + + spiderfyOpen = false; + + // --------------------------------- + // REPAINT + // --------------------------------- + + if (mapboxMap) { + mapboxMap.triggerRepaint(); + } + + showClusterMarkers(); + + // --------------------------------- + // RICOSTRUISCI CLUSTER + // --------------------------------- + + if ( + window.MapboxPhotoClusters + ) { + window.MapboxPhotoClusters.update(); + } + + // --------------------------------- + // RICOSTRUISCI FOTO + // --------------------------------- + + if ( + window.MapboxPhotoMarkers + ) { + window.MapboxPhotoMarkers.update( + getAllPhotos(), + ); + } + + console.log( + "[PhotoSpiderfy] chiusa", + ); + } + + // =================================== + // CREA MARKER SPIDER + // =================================== + + function createSpiderMarker( + feature, + id, + ) { + const img = + document.createElement("img"); + + img.src = + feature.properties?.thumb || + ""; + + img.width = 46; + img.height = 46; + + img.style.borderRadius = "7px"; + img.style.objectFit = "cover"; + img.style.border = + "2px solid white"; + img.style.cursor = "pointer"; + + // --------------------------------- + // CLICK + // --------------------------------- + + img.addEventListener( + "click", + (event) => { + event.stopPropagation(); + + const item = + window.getMapPhotoItem?.(id) || + ( + typeof window.getLocalItems === "function" + ? window.getLocalItems().find( + photo => String(photo.id) === id, + ) + : null + ); + + if (item) { + window.openPhotoMapItem?.( + item, + ); + } + + clear(); + }, + ); + + // --------------------------------- + // MARKER + // --------------------------------- + + const marker = + new mapboxgl.Marker({ + element: img, + }) + .setLngLat( + feature.geometry.coordinates, + ) + .addTo(mapboxMap); + + marker.__originalLngLat = + feature.geometry.coordinates; + + marker.__spider = true; + + marker.__photoId = id; + + return marker; + } + + // =================================== + // SPIDERFY FOTO + // =================================== + + function spiderfyPhotos( + features, + ) { + if ( + !features?.length || + !mapboxMap + ) { + return; + } + + const center = + features[0].geometry.coordinates; + + // --------------------------------- + // RAGGIERA DINAMICA + // --------------------------------- + + const count = + features.length; + + const radiusPx = Math.min( + 69, + Math.max( + 20, + 20 + count * 5, + ), + ); + + console.log( + "[PhotoSpiderfy] raggiera", + { + foto: count, + radiusPx, + }, + ); + + // --------------------------------- + // CENTRO PIXEL + // --------------------------------- + + const centerPoint = + mapboxMap.project(center); + + hideClusterMarkers(); + + // --------------------------------- + // CREA MARKER + // --------------------------------- + + features.forEach( + (feature, index) => { + const id = String( + feature.properties?.id, + ); + + /* + * Se esiste già un marker normale, + * lo rimuoviamo temporaneamente. + */ + if ( + window.MapboxPhotoMarkers + ) { + const existing = + window.MapboxPhotoMarkers.get( + id, + ); + + if (existing) { + existing.remove(); + } + } + + // -------------------------------- + // CREA MARKER SPIDER + // -------------------------------- + + const marker = + createSpiderMarker( + feature, + id, + ); + + // -------------------------------- + // POSIZIONE + // -------------------------------- + + const angle = + (Math.PI * 2 * index) / + features.length; + + const point = { + x: + centerPoint.x + + Math.cos(angle) * + radiusPx, + + y: + centerPoint.y + + Math.sin(angle) * + radiusPx, + }; + + const lngLat = + mapboxMap.unproject( + point, + ); + + marker.setLngLat( + lngLat, + ); + + marker.__originalLngLat = + feature.geometry.coordinates; + + spiderMarkers.push( + marker, + ); + }, + ); + + spiderfyOpen = true; + + console.log( + "[PhotoSpiderfy] aperto:", + features.length, + ); + } + + // =================================== + // SMART ZOOM + // =================================== + + function smartZoomCluster( + clusterId, + center, + ) { + if (!mapboxMap) { + return; + } + + const source = + mapboxMap.getSource( + SOURCE_ID, + ); + + if (!source) { + return; + } + + const MAX_STEPS = 5; + + let steps = 0; + + function zoomStep() { + if ( + steps >= MAX_STEPS + ) { + console.log( + "[PhotoSpiderfy] smart zoom limite", + ); + + return; + } + + steps++; + + source.getClusterExpansionZoom( + Number(clusterId), + (err, expansionZoom) => { + if (err) { + console.error( + "[PhotoSpiderfy] smart zoom error", + err, + ); + + return; + } + + const currentZoom = + mapboxMap.getZoom(); + + const targetZoom = + Math.max( + expansionZoom, + currentZoom + 1, + ); + + console.log( + "[PhotoSpiderfy] smart zoom step", + { + clusterId, + step: steps, + currentZoom, + expansionZoom, + targetZoom, + }, + ); + + mapboxMap.once( + "moveend", + () => { + const hits = + mapboxMap.querySourceFeatures( + SOURCE_ID, + { + filter: [ + "has", + "point_count", + ], + }, + ); + + const sameCluster = + hits.some( + (feature) => + Number( + feature + .properties + ?.cluster_id, + ) === + Number( + clusterId, + ), + ); + + console.log( + "[PhotoSpiderfy] smart zoom check", + { + clusterId, + sameCluster, + zoom: + mapboxMap.getZoom(), + }, + ); + + if (sameCluster) { + zoomStep(); + } else { + console.log( + "[PhotoSpiderfy] cluster espanso", + ); + + if ( + window + .MapboxPhotoClusters + ) { + window + .MapboxPhotoClusters + .update(); + } + } + }, + ); + + mapboxMap.easeTo({ + center, + zoom: targetZoom, + duration: 700, + }); + }, + ); + } + + zoomStep(); + } + + // =================================== + // CLICK CLUSTER + // =================================== + + function handleClusterClick( + clusterId, + center, + ) { + if (!mapboxMap) { + return; + } + + const source = + mapboxMap.getSource( + SOURCE_ID, + ); + + if (!source) { + return; + } + + console.log( + "[PhotoSpiderfy] cluster click", + { + clusterId, + center, + }, + ); + + source.getClusterLeaves( + Number(clusterId), + 100, + 0, + (err, leaves) => { + if (err) { + console.error( + "[PhotoSpiderfy] cluster leaves error", + err, + ); + + return; + } + + if (!leaves.length) { + return; + } + + console.log( + "[PhotoSpiderfy] cluster leaves:", + leaves.length, + ); + + // ================================= + // CONTROLLA SE SONO RAGGRUPPATE + // ================================= + + const first = + leaves[0].geometry.coordinates; + + const firstLng = + first[0]; + + const firstLat = + first[1]; + + const radiusMeters = 30; + + const latDelta = + radiusMeters / 111320; + + const lngDelta = + radiusMeters / + ( + 111320 * + Math.cos( + (firstLat * Math.PI) / + 180, + ) + ); + + const minLat = + firstLat - latDelta; + + const maxLat = + firstLat + latDelta; + + const minLng = + firstLng - lngDelta; + + const maxLng = + firstLng + lngDelta; + + let isSpider = true; + + for ( + let i = 1; + i < leaves.length; + i++ + ) { + const coords = + leaves[i].geometry.coordinates; + + const lng = + coords[0]; + + const lat = + coords[1]; + + if ( + lat < minLat || + lat > maxLat || + lng < minLng || + lng > maxLng + ) { + isSpider = false; + + break; + } + } + + console.log( + "[PhotoSpiderfy] cluster spider:", + isSpider, + ); + + // ================================= + // SPIDERFY + // ================================= + + if (isSpider) { + clear(); + + spiderfyPhotos( + leaves, + ); + + return; + } + + // ================================= + // SMART ZOOM + // ================================= + + smartZoomCluster( + clusterId, + center, + ); + }, + ); + } + + // =================================== + // IS OPEN + // =================================== + + function isOpen() { + return spiderfyOpen; + } + + // =================================== + // SIZE + // =================================== + + function size() { + return spiderMarkers.length; + } + + // =================================== + // DEBUG + // =================================== + + function debug() { + console.log( + "[PhotoSpiderfy] debug", + { + open: spiderfyOpen, + + markers: + spiderMarkers.length, + }, + ); + } + + // =================================== + // API PUBBLICA + // =================================== + + window.MapboxPhotoSpiderfy = { + setMap, + + clear, + + spiderfyPhotos, + + getNearbyPhotos, + + handleClusterClick, + + smartZoomCluster, + + isOpen, + + size, + + debug, + }; +})(); diff --git a/public/js/map/photosLayer.js b/public/js/map/photosLayer.js new file mode 100644 index 0000000..13da95b --- /dev/null +++ b/public/js/map/photosLayer.js @@ -0,0 +1,411 @@ +// public/js/mapPhotosLayer.js +// =============================== +// LAYER MAPPA FOTO +// =============================== + +(function () { + let visiblePhotoMarkers = new Map(); + + let mapPhotoItems = []; + let mapPhotoById = new Map(); + let mapPhotoLoadPromise = null; + let mapPhotoLoadGeneration = 0; + let mapPhotoIndexLoaded = false; + + let mapPhotoBoundsKey = null; + let mapPhotoRequestedBoundsKey = null; + + function getPhotoMapBounds() { + const bounds = window.MapEngine?.getBounds?.() || window.globalMap?.getBounds?.(); + if (!bounds) return null; + + const west = bounds.getWest(); + const south = bounds.getSouth(); + const east = bounds.getEast(); + const north = bounds.getNorth(); + + if (![west, south, east, north].every(Number.isFinite)) { + return null; + } + + return { + west, south, east, north, + key: [west, south, east, north] + .map(v => v.toFixed(5)).join(","), + }; + } + + async function loadMapPhotoIndex() { + const bounds = getPhotoMapBounds(); + if (!bounds) return false; + + if (mapPhotoIndexLoaded && mapPhotoBoundsKey === bounds.key) { + return true; + } + + if ( + mapPhotoLoadPromise && + mapPhotoRequestedBoundsKey === bounds.key + ) { + return mapPhotoLoadPromise; + } + + const token = localStorage.getItem("token"); + if (!token) return false; + + const generation = ++mapPhotoLoadGeneration; + mapPhotoRequestedBoundsKey = bounds.key; + mapPhotoIndexLoaded = false; + + const params = new URLSearchParams({ + west: String(bounds.west), + south: String(bounds.south), + east: String(bounds.east), + north: String(bounds.north), + limit: "2500", + }); + + if (localStorage.getItem("showDeleted") === "1") { + params.set("showDeleted", "1"); + } + + const promise = (async () => { + try { + const response = await fetch( + "/photos/map?" + params.toString(), + { + headers: { Authorization: "Bearer " + token }, + } + ); + + if (!response.ok) { + throw new Error("HTTP " + response.status); + } + + const data = await response.json(); + + if (generation !== mapPhotoLoadGeneration) return false; + if (!Array.isArray(data.items)) { + throw new Error("Risposta map senza array items"); + } + + mapPhotoItems = data.items.filter(item => + Number.isFinite(Number(item.gps?.lat)) && + Number.isFinite(Number(item.gps?.lng)) + ); + + mapPhotoById = new Map( + mapPhotoItems.map(item => [String(item.id), item]) + ); + + window.MapSpatialIndex.build(mapPhotoItems); + mapPhotoBoundsKey = bounds.key; + mapPhotoIndexLoaded = true; + + console.log("[mapPhotosLayer] area caricata", { + foto: mapPhotoItems.length, + truncated: Boolean(data.truncated), + bounds: bounds.key, + }); + + if (data.truncated) { + console.warn( + "[mapPhotosLayer] raggiunto il limite di risultati; ingrandisci la mappa" + ); + } + + window.renderPhotoMapLayer?.(); + return true; + } catch (error) { + if (generation === mapPhotoLoadGeneration) { + console.error("[mapPhotosLayer] caricamento area fallito:", error); + } + return false; + } finally { + if (generation === mapPhotoLoadGeneration) { + mapPhotoLoadPromise = null; + } + } + })(); + + mapPhotoLoadPromise = promise; + return promise; + } + + window.getMapPhotoItem = function (id) { + return mapPhotoById.get(String(id)) || null; + }; + + window.refreshMapPhotoIndex = function () { + mapPhotoLoadGeneration++; + mapPhotoLoadPromise = null; + mapPhotoIndexLoaded = false; + mapPhotoBoundsKey = null; + mapPhotoRequestedBoundsKey = null; + mapPhotoItems = []; + mapPhotoById.clear(); + + window.MapSpatialIndex?.build?.([]); + window.clearPhotoMapLayer?.(false); + + return loadMapPhotoIndex(); + }; + + +window.clearPhotoMapLayer = + function (clearFactoryCache = false) { + if (window.globalMarkers) { + visiblePhotoMarkers = + MapLayerManager.sync( + window.globalMarkers, + visiblePhotoMarkers, + [], + ); + } else { + visiblePhotoMarkers.clear(); + } + + if (clearFactoryCache) { + PhotoMarkerFactory.clear(); + } + + console.log( + "[mapPhotosLayer] marker Leaflet puliti", + { + visible: + visiblePhotoMarkers.size, + + cache: + PhotoMarkerFactory.size(), + + clusterLayers: + window.globalMarkers + ?.getLayers?.() + ?.length ?? null, + }, + ); + }; + + window.photoMapDebug = { + cacheSize() { + return PhotoMarkerFactory.size(); + }, + + visibleSize() { + return visiblePhotoMarkers.size; + }, + +clearCache() { + window.clearPhotoMapLayer?.( + true, + ); +}, + }; + + // =============================== + // APRI FOTO + // =============================== + window.openPhotoMapItem = function (item) { + const thumb = toAbsoluteUrl( + item.thub1 || item.thub2 || item.path, + item.name, + "thumbs", + item.cartella, + ); + + const original = toAbsoluteUrl( + item.path, + item.name, + "original", + item.cartella, + ); + + window.openModal(item); + //window.openModal?.(original, thumb, item); + }; + + // =============================== + // RENDER FOTO + // =============================== +window.renderPhotoMapLayer = + function () { + if ( + window.state?.mode !== + "photos" + ) { + return; + } + + if (!window.globalMap) { + return; + } + + if (!window.MapEngine?.isReady?.()) { + console.warn("[mapPhotosLayer] MapEngine non pronto"); + return; + } + + const bounds = window.MapEngine?.getBounds?.() || window.globalMap.getBounds(); + const area = getPhotoMapBounds(); + + if ( + !area || + !mapPhotoIndexLoaded || + mapPhotoBoundsKey !== area.key + ) { + loadMapPhotoIndex(); + return; + } + + const items = MapSpatialIndex.search(bounds); + + const markers = []; + + for (const entry of items) { + const lat = entry.lat; + const lng = entry.lng; + + const photo = entry.item; + + if (!Number.isFinite(lat) || !Number.isFinite(lng)) { + continue; + } + + const marker = PhotoMarkerFactory.get(photo); + + markers.push(marker); + } + + visiblePhotoMarkers = MapLayerManager.sync( + window.globalMarkers, + visiblePhotoMarkers, + markers, + ); + +for (const marker of markers) { + if ( + marker && + !window.globalMarkers + ?.hasLayer?.(marker) + ) { + window.globalMarkers + ?.addLayer?.(marker); + } +} + +console.log( + "[mapPhotosLayer] marker sincronizzati", + { + visible: + visiblePhotoMarkers.size, + + clusterLayers: + window.globalMarkers + ?.getLayers?.() + ?.length ?? null, + }, +); + + if (window.MapTerrain3D?.enabled?.()) { + const features = window.getPhotoMapGeoJSON?.() || []; + window.MapboxMarkers?.setPhotos(features); + } + + console.log( + "[mapPhotosLayer]", + "visibili:", + visiblePhotoMarkers.size, + "cache:", + PhotoMarkerFactory.size(), + "indice:", + items.length, + ); + }; + + // =============================== + // GEOJSON PER MAPBOX + // =============================== + window.getPhotoMapGeoJSON = function () { + const area = getPhotoMapBounds(); + + if ( + !area || + !mapPhotoIndexLoaded || + mapPhotoBoundsKey !== area.key + ) { + loadMapPhotoIndex(); + return []; + } + + const features = []; + + let items = []; + + /* +if (window.MapEngine?.isMapbox?.()) { + items = MapSpatialIndex.search({ + getWest: () => -180, + getEast: () => 180, + getSouth: () => -90, + getNorth: () => 90, + }); +} else { + const bounds = window.MapEngine?.getBounds?.(); + + items = bounds + ? MapSpatialIndex.search(bounds) + : []; +} +*/ + const bounds = window.MapEngine?.getBounds?.(); + + items = bounds ? MapSpatialIndex.search(bounds) : []; + + console.log("[PhotoGeoJSON DEBUG bounds]", { + west: bounds?.getWest?.(), + east: bounds?.getEast?.(), + south: bounds?.getSouth?.(), + north: bounds?.getNorth?.(), + }); + + console.log("[PhotoGeoJSON DEBUG items]", items.length); + + for (const entry of items) { + const lat = entry.lat; + const lng = entry.lng; + + const photo = entry.item; + + if (!Number.isFinite(lat) || !Number.isFinite(lng)) { + continue; + } + + features.push({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [lng, lat], + }, + /* properties: { + id: photo.id, + type: "photo", + name: photo.name || "", + },*/ + properties: { + id: photo.id, + type: "photo", + name: photo.name || "", + + thumb: toAbsoluteUrl( + photo.thub1 || photo.thub2 || photo.path, + photo.name, + "thumbs", + photo.cartella, + ), + }, + }); + } + + return features; + }; +})(); diff --git a/public/js/map/spatialIndex.js b/public/js/map/spatialIndex.js new file mode 100644 index 0000000..0d008b4 --- /dev/null +++ b/public/js/map/spatialIndex.js @@ -0,0 +1,143 @@ +// public/js/map/spatialIndex.js +// =============================== +// INDICE SPAZIALE FOTO (RBush) +// =============================== + +(function () { + "use strict"; + + if (!window.GP) { + window.GP = {}; + } + + if (!window.GP.libs?.RBush) { + console.error("[spatialIndex] RBush non disponibile"); + return; + } + + const tree = new window.GP.libs.RBush(); + const indexedIds = new Set(); + + let indexed = false; + let totalItems = 0; + + function makeNode(item) { + const lat = Number(item?.gps?.lat); + const lng = Number(item?.gps?.lng); + + if ( + item?.id == null || + !Number.isFinite(lat) || + !Number.isFinite(lng) || + lat < -90 || lat > 90 || + lng < -180 || lng > 180 + ) { + return null; + } + + return { + minX: lng, + minY: lat, + maxX: lng, + maxY: lat, + id: String(item.id), + lat, + lng, + item, + }; + } + + function build(items) { + tree.clear(); + indexedIds.clear(); + totalItems = 0; + + const nodes = []; + + for (const item of (Array.isArray(items) ? items : [])) { + const node = makeNode(item); + if (!node || indexedIds.has(node.id)) continue; + + indexedIds.add(node.id); + nodes.push(node); + } + + tree.load(nodes); + + indexed = true; + totalItems = nodes.length; + + console.log("[spatialIndex] indicizzate", totalItems, "foto"); + } + + function append(items) { + if (!indexed) { + build(items); + return; + } + + let added = 0; + + for (const item of (Array.isArray(items) ? items : [])) { + const node = makeNode(item); + if (!node || indexedIds.has(node.id)) continue; + + indexedIds.add(node.id); + tree.insert(node); + totalItems++; + added++; + } + + if (added) { + console.log( + "[spatialIndex] aggiunte", added, + "foto; totale", totalItems + ); + } + } + + function search(bounds) { + if (!indexed) { + return []; + } + + // ricerca globale + if (!bounds) { + return tree.all(); + } + + return tree.search({ + minX: bounds.getWest(), + minY: bounds.getSouth(), + maxX: bounds.getEast(), + maxY: bounds.getNorth(), + }); + } + + function clear() { + tree.clear(); + indexedIds.clear(); + indexed = false; + totalItems = 0; + } + + function stats() { + return { + indexed, + totalItems, + }; + } + + function isBuilt() { + return indexed; + } + + window.MapSpatialIndex = { + build, + append, + search, + clear, + stats, + isBuilt, + }; +})(); diff --git a/public/js/map/terrain3d.js b/public/js/map/terrain3d.js new file mode 100644 index 0000000..47fe007 --- /dev/null +++ b/public/js/map/terrain3d.js @@ -0,0 +1,960 @@ +// public/js/map/terrain3d.js +// Switch Leaflet <-> Mapbox GL Terrain 3D + +(() => { + "use strict"; + + let mapboxMap = null; + let enabled = false; + let savedLeafletView = null; + let savedLeafletMap = null; + let terrainMarkerRefreshTimer = null; + let terrainMarkerRefreshToken = 0; + let terrainEventsMap = null; + let destroying = false; + + function getToken() { + return window.AppConfig?.mapboxToken || ""; + } + + function getContainer() { + return document.getElementById("terrainMap"); + } + + function getLeafletMap() { + if (window.MapEngine?.type === "mapbox") { + return savedLeafletMap; + } + + return ( + window.GlobalMap?.getMap?.() || + window.MapEngine?.map || + window.leafletMap || + null + ); + } + + function createMapbox3D() { + const container = getContainer(); + + if (!container) { + console.error("[Terrain3D] #terrainMap non trovato"); + + return; + } + + const token = getToken(); + + if (!token) { + console.error("[Terrain3D] Token Mapbox mancante"); + + return; + } + + if (!window.mapboxgl) { + console.error("[Terrain3D] Mapbox GL JS non caricato"); + + return; + } + + // Impedisce la creazione + // di una seconda istanza. + if (mapboxMap) { + console.warn("[Terrain3D] Mapbox già inizializzato"); + + return; + } + + // ================================= + // MOSTRA IL CONTENITORE MAPBOX + // ================================= + + container.classList.add("open"); + + container.style.display = "block"; + + container.style.visibility = "visible"; + + container.style.pointerEvents = "auto"; + + // ================================= + // RECUPERA LA MAPPA LEAFLET + // ================================= + + if (!savedLeafletMap) { + savedLeafletMap = getLeafletMap(); + } + + const leafletMap = savedLeafletMap; + + console.log("[Terrain3D] Leaflet trovato:", leafletMap); + + // ================================= + // VISTA INIZIALE + // ================================= + + let center = [12.4964, 41.9028]; + + let zoom = 6; + + if (leafletMap) { + const leafletCenter = leafletMap.getCenter(); + + center = [leafletCenter.lng, leafletCenter.lat]; + + zoom = leafletMap.getZoom(); + + savedLeafletView = { + lat: leafletCenter.lat, + + lng: leafletCenter.lng, + + zoom: leafletMap.getZoom(), + }; + + console.log("[Terrain3D] Vista Leaflet salvata", savedLeafletView); + } + + // ================================= + // NASCONDE LEAFLET + // ================================= + + if (leafletMap) { + const leafletMapContainer = leafletMap.getContainer(); + + leafletMapContainer.style.visibility = "hidden"; + + leafletMapContainer.style.pointerEvents = "none"; + + console.log("[Terrain3D] Leaflet nascosto"); + } + + // ================================= + // CREA MAPBOX + // ================================= + + mapboxgl.accessToken = token; + + mapboxMap = new mapboxgl.Map({ + container, + + style: "mapbox://styles/mapbox/outdoors-v12", + + center, + zoom, + + pitch: 60, + bearing: -20, + + antialias: true, + }); + + // ================================= + // CARICAMENTO MAPBOX + // ================================= + + mapboxMap.on("load", () => { + if (!mapboxMap) { + return; + } + + // ------------------------------- + // SOURCE DEL TERRENO + // ------------------------------- + + if (!mapboxMap.getSource("mapbox-dem")) { + mapboxMap.addSource("mapbox-dem", { + type: "raster-dem", + + url: "mapbox://mapbox.mapbox-terrain-dem-v1", + + tileSize: 512, + maxzoom: 14, + }); + } + + // ------------------------------- + // TERRENO 3D + // ------------------------------- + + mapboxMap.setTerrain({ + source: "mapbox-dem", + + exaggeration: 1.5, + }); + + // ------------------------------- + // CONTROLLI MAPBOX + // ------------------------------- + + mapboxMap.addControl( + new mapboxgl.NavigationControl(), + + "top-right", + ); + + // ------------------------------- + // COLLEGA MAPENGINE + // ------------------------------- + + if (window.MapEngine) { + window.MapEngine.setMapbox(mapboxMap); + } + + // ------------------------------- + // COLLEGA MARKER CONTROLLER + // ------------------------------- + + window.MapboxMarkers?.setMap?.(mapboxMap); + + window.MapBaseLayers?.mountTerrainStyleControl?.(container); + + // ------------------------------- + // ATTIVA MAPBOX 3D + // ------------------------------- + + /* + * enabled deve diventare true prima + * di registrare e utilizzare il refresh. + */ + + + + enabled = true; + + + + + + + +const activitiesMode = + window.state?.mode === + "activities"; + +if (activitiesMode) { + window.showActivityRoutesToggle?.(); +} else { + window.hideActivityRoutesToggle?.( + true, + ); +} + + + + + + + + + // ------------------------------- + // COLLEGA MOVEEND + // ------------------------------- + + /* + * Registra il listener nominato + * handleTerrainMoveEnd. + * + * Non devono esserci altri listener + * anonimi moveend in questa funzione. + */ + setupTerrainMapEvents(); + + // ------------------------------- + // PRIMO REFRESH + // ------------------------------- + + /* + * Aspetta il completamento del primo + * rendering prima di caricare marker, + * cluster e route. + */ + mapboxMap.once("idle", () => { + scheduleTerrainMarkerRefresh("initial-idle"); + }); + + console.log("[Terrain3D] Mapbox 3D attivo"); + + // ------------------------------- + // AGGIORNA LE DIMENSIONI + // ------------------------------- + + requestAnimationFrame(() => { + mapboxMap?.resize?.(); + }); + }); + + // ================================= + // ERRORI MAPBOX + // ================================= + + mapboxMap.on("error", (event) => { + console.error("[Terrain3D] errore Mapbox", event?.error || event); + }); + } + + + function destroyMapbox3D() { + if (!mapboxMap) { + return; + } + + destroying = true; + console.log("[Terrain3D] Chiusura MapboxGL"); + + const mapboxCenter = mapboxMap.getCenter?.(); + + const mapboxZoom = mapboxMap.getZoom?.(); + + if ( + mapboxCenter && + Number.isFinite(Number(mapboxCenter.lat)) && + Number.isFinite(Number(mapboxCenter.lng)) && + Number.isFinite(Number(mapboxZoom)) + ) { + savedLeafletView = { + lat: Number(mapboxCenter.lat), + + lng: Number(mapboxCenter.lng), + + zoom: Number(mapboxZoom), + }; + } + + removeTerrainMapEvents(); + + /* + * Pulisce marker e cluster mentre + * la vecchia mappa è ancora valida. + */ + window.MapboxMarkers?.clearMarkers?.(); + + window.MapBaseLayers?.removeTerrainStyleControl?.(); + + /* + * Soltanto dopo la pulizia viene + * distrutta l'istanza Mapbox. + */ + mapboxMap.remove(); + mapboxMap = null; + enabled = false; + destroying = false; + + const terrainContainer = document.getElementById("terrainMap"); + + if (terrainContainer) { + terrainContainer.classList.remove("open"); + + terrainContainer.style.display = "none"; + } + + const globalMapContainer = document.getElementById("globalMap"); + + if (globalMapContainer) { + globalMapContainer.style.visibility = "visible"; + + globalMapContainer.classList.add("open"); + } + + const leafletMap = savedLeafletMap; + + if (leafletMap) { + const leafletMapContainer = leafletMap.getContainer(); + + leafletMapContainer.style.display = "block"; + + leafletMapContainer.style.visibility = "visible"; + + leafletMapContainer.style.pointerEvents = "auto"; + + leafletMap.invalidateSize(); + + if (savedLeafletView) { + leafletMap.setView( + [savedLeafletView.lat, savedLeafletView.lng], + savedLeafletView.zoom, + ); + } + + console.log("[Terrain3D] Leaflet ripristinato"); + } + +if ( + savedLeafletMap && + window.MapEngine +) { + window.MapEngine.type = + "leaflet"; + + window.MapEngine.map = + savedLeafletMap; + + window.MapEngine.markerLayer = + window.globalMarkers; +} + +requestAnimationFrame( + async () => { + if (!savedLeafletMap) { + return; + } + + savedLeafletMap.invalidateSize?.(); + + if ( + window.globalMarkers && + !savedLeafletMap.hasLayer( + window.globalMarkers, + ) + ) { + savedLeafletMap.addLayer( + window.globalMarkers, + ); + } + + window.globalMarkers + ?.clearLayers?.(); + + if ( + window.state?.mode === + "activities" + ) { + await window + .renderActivityMapLayer?.(); + } else { + await window + .renderPhotoMapLayer?.(); + } + + savedLeafletMap.invalidateSize?.(); + + console.log( + "[Terrain3D] Leaflet ridisegnato", + { + mode: + window.state?.mode, + + markerLayers: + window.globalMarkers + ?.getLayers?.() + ?.length ?? null, + + clusterOnMap: + !!( + window.globalMarkers && + savedLeafletMap.hasLayer( + window.globalMarkers, + ) + ), + }, + ); + }, +); + +console.log( + "[Terrain3D] ritorno Leaflet", +); + } + + function waitForMapboxReady(timeoutMs = 2500) { + return new Promise((resolve) => { + if (!mapboxMap) { + resolve(false); + return; + } + + if (mapboxMap.isStyleLoaded?.() && !mapboxMap.isMoving?.()) { + resolve(true); + return; + } + + let completed = false; + + const finish = () => { + if (completed) { + return; + } + + completed = true; + + clearTimeout(timeout); + + mapboxMap?.off?.("idle", handleReady); + + resolve(!!mapboxMap && !!mapboxMap.isStyleLoaded?.()); + }; + + const handleReady = () => { + if (!mapboxMap) { + finish(); + return; + } + + if (mapboxMap.isStyleLoaded?.() && !mapboxMap.isMoving?.()) { + finish(); + } + }; + + const timeout = setTimeout(finish, timeoutMs); + + mapboxMap.on("idle", handleReady); + }); + } + + function scheduleTerrainMarkerRefresh(reason = "moveend") { + if (!mapboxMap) { + return; + } + + if (!enabled) { + return; + } + + if (destroying) { + return; + } + + if (terrainMarkerRefreshTimer) { + clearTimeout(terrainMarkerRefreshTimer); + } + + const token = ++terrainMarkerRefreshToken; + + terrainMarkerRefreshTimer = setTimeout(async () => { + terrainMarkerRefreshTimer = null; + + if (!mapboxMap || !enabled) { + return; + } + + if (token !== terrainMarkerRefreshToken) { + return; + } + + /* + * Dopo moveend Mapbox può ancora + * avere isStyleLoaded() false. + * Aspettiamo il successivo idle. + */ + const ready = await waitForMapboxReady(); + + if (!ready || !mapboxMap || !enabled) { + console.warn("[Terrain3D] refresh annullato, Mapbox non pronto", { + reason, + token, + }); + + return; + } + + if (token !== terrainMarkerRefreshToken) { + return; + } + + if (typeof window.refreshTerrainMarkers !== "function") { + return; + } + + console.log("[Terrain3D] aggiorno marker", { + reason, + token, + + styleLoaded: mapboxMap.isStyleLoaded?.(), + + moving: mapboxMap.isMoving?.(), + + zoom: mapboxMap.getZoom?.(), + + bounds: mapboxMap.getBounds?.()?.toArray?.(), + }); + + await window.refreshTerrainMarkers(); + }, 180); + } + + function handleTerrainMoveEnd() { + scheduleTerrainMarkerRefresh("moveend"); + } + + function setupTerrainMapEvents() { + if (!mapboxMap) { + return; + } + + if (terrainEventsMap === mapboxMap) { + return; + } + + removeTerrainMapEvents(); + + terrainEventsMap = mapboxMap; + + terrainEventsMap.on("moveend", handleTerrainMoveEnd); + + console.log("[Terrain3D] listener moveend collegato"); + } + + function removeTerrainMapEvents() { + if (terrainMarkerRefreshTimer) { + clearTimeout(terrainMarkerRefreshTimer); + + terrainMarkerRefreshTimer = null; + } + + terrainMarkerRefreshToken++; + + if (!terrainEventsMap) { + return; + } + + terrainEventsMap.off("moveend", handleTerrainMoveEnd); + + terrainEventsMap = null; + + console.log("[Terrain3D] listener moveend rimosso"); + } + + async function enable() { + // Mapbox è già attivo. + if (enabled && mapboxMap) { + mapboxMap.resize?.(); + + return true; + } + + // Rimuove un'istanza rimasta + // parzialmente inizializzata. + if (mapboxMap && !enabled) { + console.warn("[Terrain3D] rimuovo istanza Mapbox incompleta"); + + try { + removeTerrainMapEvents(); + + window.MapBaseLayers?.removeTerrainStyleControl?.(); + + window.MapboxMarkers?.clearMarkers?.(); + + mapboxMap.remove(); + } catch (error) { + console.warn("[Terrain3D] errore pulizia istanza incompleta", error); + } + + mapboxMap = null; + } + + // Avvia una nuova istanza Mapbox. + createMapbox3D(); + + const success = await new Promise((resolve) => { + let completed = false; + let check = null; + let timeout = null; + + const finish = (result) => { + if (completed) { + return; + } + + completed = true; + + if (check !== null) { + clearInterval(check); + + check = null; + } + + if (timeout !== null) { + clearTimeout(timeout); + + timeout = null; + } + + resolve(result); + }; + + check = setInterval(() => { + if (!mapboxMap) { + return; + } + + if (!enabled) { + return; + } + + if (!mapboxMap.isStyleLoaded?.()) { + return; + } + + mapboxMap.resize?.(); + + finish(true); + }, 50); + + timeout = setTimeout(() => { + finish(false); + }, 10000); + }); + + window.updateTerrainButton?.(enabled); + + if (!success) { + console.warn("[Terrain3D] attivazione non completata entro 10 secondi"); + + // Pulisce l'istanza che non ha + // completato correttamente il load. + if (mapboxMap) { + try { + removeTerrainMapEvents(); + + window.MapBaseLayers?.removeTerrainStyleControl?.(); + + window.MapboxMarkers?.clearMarkers?.(); + + mapboxMap.remove(); + } catch (error) { + console.warn("[Terrain3D] errore pulizia dopo timeout", error); + } + } + + mapboxMap = null; + enabled = false; + + window.updateTerrainButton?.(false); + + return false; + } + + console.log("[Terrain3D] attivazione completata"); + + return true; + } + + function disable() { + if (!mapboxMap) { + enabled = false; + + window.updateTerrainButton?.(false); + + return; + } + + destroyMapbox3D(); + + enabled = false; + + window.updateTerrainButton?.(false); + } + + async function toggle() { + if (enabled) { + disable(); + } else { + await enable(); + } + } + + function isEnabled() { + return enabled; + } + + function isActivityItem(item) { + if (!item) { + return false; + } + + const format = String( + item.format || + item.file_extension || + item.mime_type || + item.file_name || + item.name || + "", + ).toLowerCase(); + + return ( + format.includes("fit") || + format.includes("gpx") || + format.includes("tcx") || + item.distance_meters != null || + item.duration_millis != null || + item.track_preview != null + ); + } + + window.refreshTerrainMarkers = async function () { + if (!mapboxMap) { + return; + } + + if (!enabled) { + return; + } + + if (destroying) { + return; + } + + const items = + typeof window.getLocalItems === "function" ? window.getLocalItems() : []; + +const isActivitiesMode = + window.state?.mode === + "activities"; + + let photos = []; + let activities = []; + let routes = []; + +if (isActivitiesMode) { + window.showActivityRoutesToggle?.(); + + if ( + window.MapboxPhotoSpiderfy + ?.isOpen?.() + ) { + window.MapboxPhotoSpiderfy + ?.clear?.(); + } + + window.MapboxPhotoMarkers + ?.clear?.(); + + window.MapboxPhotoClusters + ?.clear?.(); + + window.MapboxMarkers + ?.setPhotos?.([]); + + activities = + typeof window + .getActivityMapGeoJSON === + "function" + ? window + .getActivityMapGeoJSON() + : []; + + routes = + typeof window + .getActivityRoutesGeoJSON === + "function" + ? window + .getActivityRoutesGeoJSON() + : []; + + if ( + activities.length === 0 && + typeof window + .renderActivityMapLayer === + "function" + ) { + console.log( + "[Terrain3D] caricamento iniziale attività", + { + localItems: + items.length, + }, + ); + + await window + .renderActivityMapLayer(); + + activities = + typeof window + .getActivityMapGeoJSON === + "function" + ? window + .getActivityMapGeoJSON() + : []; + + routes = + typeof window + .getActivityRoutesGeoJSON === + "function" + ? window + .getActivityRoutesGeoJSON() + : []; + } + + window.MapboxMarkers + ?.setActivities?.( + activities, + ); + + const showRoutes = + window + .areActivityRoutesVisible?.() ?? + false; + + window.MapboxMarkers + ?.setRoutes?.( + showRoutes + ? routes + : [], + ); +} else { + window.hideActivityRoutesToggle?.( + true, + ); + + if ( + window.MapboxActivitySpiderfy + ?.isOpen?.() + ) { + window.MapboxActivitySpiderfy + ?.clear?.(); + } + + window.MapboxActivityMarkers + ?.clear?.(); + + window.MapboxActivityClusters + ?.clear?.(); + + window.MapboxMarkers + ?.setActivities?.([]); + + window.MapboxMarkers + ?.setRoutes?.([]); + + photos = + typeof window + .getPhotoMapGeoJSON === + "function" + ? window + .getPhotoMapGeoJSON() + : []; + + window.MapboxMarkers + ?.setPhotos?.( + photos, + ); +} + + console.log("[Terrain3D] marker aggiornati", { + mode: isActivitiesMode ? "activities" : "photos", + + localItems: items.length, + + photos: photos.length, + + activities: activities.length, + + routesAvailable: routes.length, + + routesVisible: isActivitiesMode + ? (window.areActivityRoutesVisible?.() ?? false) + : false, + + styleLoaded: !!mapboxMap?.isStyleLoaded?.(), + + moving: !!mapboxMap?.isMoving?.(), + }); + }; + + window.MapTerrain3D = Object.freeze({ + enable, + disable, + toggle, + enabled: isEnabled, + }); +})(); diff --git a/public/js/modal.js b/public/js/modal.js new file mode 100644 index 0000000..30f4f6f --- /dev/null +++ b/public/js/modal.js @@ -0,0 +1,390 @@ +// =============================== +// MODALE FOTO/VIDEO — Navigazione + Info Panel + Preload +// =============================== + +const modal = document.getElementById("modal"); +const modalClose = document.getElementById("modalClose"); +const modalPrev = document.getElementById("modalPrev"); +const modalNext = document.getElementById("modalNext"); + +window.currentPhoto = null; +window.modalList = []; +window.modalIndex = 0; + +// =============================== +// INFO PANEL — Stato + Toggle +// =============================== + +function getInfoPanel() { + return document.getElementById("infoPanel"); +} + +function isInfoOpen() { + const panel = getInfoPanel(); + + return !!( + panel?.classList.contains("open") || + panel?.getAttribute("aria-hidden") === "false" || + panel?.getAttribute("data-open") === "1" || + panel?.style.display === "block" + ); +} + +function openInfo(photo) { + try { + if (typeof window.openInfoPanel === "function") { + window.openInfoPanel(photo); + return; + } + + if (typeof window.toggleInfoPanel === "function") { + window.toggleInfoPanel(photo); + } + } catch (err) { + console.error("[modal] openInfo error:", err); + } +} + +function closeInfo() { + try { + if (typeof window.closeInfoPanel === "function") { + window.closeInfoPanel(); + return; + } + + if (typeof window.toggleInfoPanel === "function") { + window.toggleInfoPanel(); + } + } catch (err) { + console.error("[modal] closeInfo error:", err); + } +} + +function toggleInfo(photo) { + if (isInfoOpen()) { + closeInfo(); + } else { + openInfo(photo); + } +} + + +// =============================== +// MIME / MEDIA HELPERS +// =============================== +function isProbablyVideo(photo, srcOriginal) { + const mime = String(photo?.mime_type || "").toLowerCase(); + if (mime.startsWith("video/")) return true; + + return /\.(mp4|m4v|webm|mov|qt|avi|mkv)$/i.test(String(srcOriginal || "")); +} + +function guessVideoMime(photo, srcOriginal) { + const t = String(photo?.mime_type || "").toLowerCase(); + if (t && t !== "application/octet-stream") return t; + + const src = String(srcOriginal || ""); + if (/\.(mp4|m4v)$/i.test(src)) return "video/mp4"; + if (/\.(webm)$/i.test(src)) return "video/webm"; + if (/\.(mov|qt)$/i.test(src)) return "video/quicktime"; + if (/\.(avi)$/i.test(src)) return "video/x-msvideo"; + if (/\.(mkv)$/i.test(src)) return "video/x-matroska"; + + return ""; +} + +function createVideoElement(srcOriginal, srcPreview, photo) { + const video = document.createElement("video"); + video.controls = true; + video.playsInline = true; + video.setAttribute("webkit-playsinline", ""); + video.preload = "metadata"; + video.poster = srcPreview || ""; + video.style.maxWidth = "100%"; + video.style.maxHeight = "100%"; + video.style.objectFit = "contain"; + + const source = document.createElement("source"); + source.src = srcOriginal; + + const type = guessVideoMime(photo, srcOriginal); + if (type) source.type = type; + + video.appendChild(source); + + video.addEventListener("loadedmetadata", () => { + try { video.currentTime = 0.001; } catch {} + }); + + video.addEventListener("error", () => { + const code = video.error?.code; + + const msg = document.createElement("div"); + msg.style.padding = "12px"; + msg.style.color = "#fff"; + msg.style.background = "rgba(0,0,0,0.6)"; + msg.style.borderRadius = "8px"; + msg.innerHTML = ` + Impossibile riprodurre questo video nel browser. + ${code === 4 ? "Formato/codec non supportato (es. HEVC/H.265)." : "Errore durante il caricamento."} +

+ + `; + + document.getElementById("modalMediaContainer")?.appendChild(msg); + }); + + //video.addEventListener("click", e => e.stopPropagation()); + +video.addEventListener("click", e => { + e.stopPropagation(); + + // permette al click di essere gestito anche dai pannelli esterni + document.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + target: video + }) + ); +}); + + + return video; +} + +function createImageElement(srcOriginal, srcPreview) { + const img = document.createElement("img"); + img.src = srcPreview || srcOriginal || ""; + img.style.maxWidth = "100%"; + img.style.maxHeight = "100%"; + img.style.objectFit = "contain"; + + if (srcPreview && srcOriginal && srcPreview !== srcOriginal) { + const full = new Image(); + full.src = srcOriginal; + full.onload = () => { img.src = srcOriginal; }; + } + + return img; +} + +// =============================== +// URL ASSOLUTI +// =============================== +function mediaUrlsFromPhoto(photo) { + if (!photo) return { original: "", preview: "" }; + + if (window.PATH_FULL) { + return { + original: photo.path, + preview: photo.thub2 || photo.thub1 || photo.path + }; + } + + const original = toAbsoluteUrl( + photo.path, + photo.name, + "original", + photo.cartella + ); + + const preview = toAbsoluteUrl( + photo.thub2 || photo.thub1 || photo.path, + photo.name, + "thumbs", + photo.cartella + ); + + return { original, preview }; +} + +// =============================== +// PRELOAD ±N +// =============================== +function preloadNeighbors(N = 3) { + const list = window.modalList || []; + const idx = window.modalIndex || 0; + + for (let offset = 1; offset <= N; offset++) { + const iPrev = idx - offset; + const iNext = idx + offset; + + [iPrev, iNext].forEach(i => { + const p = list[i]; + if (!p) return; + + const { original, preview } = mediaUrlsFromPhoto(p); + const isVideo = isProbablyVideo(p, original); + const src = isVideo ? (preview || original) : original; + + if (!src) return; + + const img = new Image(); + img.src = src; + }); + } +} + +// =============================== +// SET CONTENUTO MODAL +// =============================== +function setModalContent(photo) { + const container = document.getElementById("modalMediaContainer"); + container.innerHTML = ""; + window.currentPhoto = photo; + + const { original, preview } = mediaUrlsFromPhoto(photo); + const isVideo = isProbablyVideo(photo, original); + + if (isVideo) { + container.appendChild(createVideoElement(original, preview, photo)); + } else { + container.appendChild(createImageElement(original, preview)); + } + + const infoBtn = document.createElement("button"); + infoBtn.id = "modalInfoBtn"; + infoBtn.className = "modal-info-btn"; + infoBtn.type = "button"; + infoBtn.setAttribute("aria-label", "Dettagli"); + infoBtn.textContent = "ℹ️"; + + infoBtn.addEventListener("click", e => { + e.stopPropagation(); + toggleInfo(photo); + }); + + container.appendChild(infoBtn); +} + +// =============================== +// OPEN / CLOSE MODAL +// =============================== +function openModal(photo) { + window.closeBottomSheet?.(); + + setModalContent(photo); + + modal.classList.add("open"); + modal.setAttribute("aria-hidden", "false"); + document.body.style.overflow = "hidden"; +} + +function closeModal() { + if (isInfoOpen()) closeInfo(); + + const v = document.querySelector("#modal video"); + if (v) { + try { v.pause(); } catch {} + v.removeAttribute("src"); + while (v.firstChild) v.removeChild(v.firstChild); + try { v.load(); } catch {} + } + + document.getElementById("modalMediaContainer").innerHTML = ""; + + modal.classList.remove("open"); + modal.setAttribute("aria-hidden", "true"); + document.body.style.overflow = ""; + + modalPrev?.classList.add("hidden"); + modalNext?.classList.add("hidden"); +} + +modalClose?.addEventListener("click", e => { + e.stopPropagation(); + closeModal(); +}); + +modal.addEventListener("click", e => { + if (e.target === modal) closeModal(); +}); + +// =============================== +// NAVIGAZIONE +// =============================== +function openAt(i) { + const list = window.modalList || []; + if (!list[i]) return; + + window.modalIndex = i; + const photo = list[i]; + + if (isInfoOpen()) openInfo(photo); + + openModal(photo); + preloadNeighbors(3); + updateArrows(); +} + +window.openModalFromList = function(list, index) { + window.modalList = Array.isArray(list) ? list : []; + window.modalIndex = Math.max(0, Math.min(index || 0, window.modalList.length - 1)); + openAt(window.modalIndex); +}; + +function showPrev() { + if (window.modalIndex > 0) openAt(window.modalIndex - 1); +} + +function showNext() { + if (window.modalIndex < (window.modalList.length - 1)) openAt(window.modalIndex + 1); +} + +document.addEventListener("keydown", e => { + if (!modal.classList.contains("open")) return; + + if (e.key === "ArrowLeft") { e.preventDefault(); showPrev(); } + if (e.key === "ArrowRight") { e.preventDefault(); showNext(); } +}); + +modal.addEventListener("click", e => { + if (!modal.classList.contains("open")) return; + + if (e.target.closest(".modal-info-btn, .modal-close, .modal-nav-btn")) return; + if (e.target === modal) return; + + const rect = modal.getBoundingClientRect(); + const x = e.clientX - rect.left; + const side = x / rect.width; + + if (side < 0.25) showPrev(); + else if (side > 0.75) showNext(); +}); + +// =============================== +// FRECCE +// =============================== +function updateArrows() { + if (!modalPrev || !modalNext) return; + + const len = (window.modalList || []).length; + const i = window.modalIndex || 0; + + const show = len > 1; + modalPrev.classList.toggle("hidden", !show); + modalNext.classList.toggle("hidden", !show); + + modalPrev.classList.toggle("disabled", i <= 0); + modalNext.classList.toggle("disabled", i >= len - 1); +} + +modalPrev?.addEventListener("click", e => { + e.stopPropagation(); + showPrev(); + updateArrows(); +}); + +modalNext?.addEventListener("click", e => { + e.stopPropagation(); + showNext(); + updateArrows(); +}); + +// EXPORT +window.openModal = openModal; +window.closeModal = closeModal; + diff --git a/public/js/operationBanner.js b/public/js/operationBanner.js new file mode 100644 index 0000000..4bebdfa --- /dev/null +++ b/public/js/operationBanner.js @@ -0,0 +1,126 @@ +(function () { + "use strict"; + + const banners = new Map(); + const GAP = 12; + const DEFAULT_ID = "default"; + + function normalizeId(id) { + return String(id || DEFAULT_ID); + } + + function getBanner(id) { + id = normalizeId(id); + + if (banners.has(id)) return banners.get(id).element; + + const banner = document.createElement("div"); + banner.className = "operation-banner"; + banner.dataset.operationBannerId = id; + banner.setAttribute("role", "status"); + banner.setAttribute("aria-live", "polite"); + + banner.innerHTML = ` +
+ +
+ +
+
+
+
+
+ `; + + banner.querySelector(".ob-close").addEventListener("click", () => hide(id)); + document.body.appendChild(banner); + + banners.set(id, { element: banner, timer: null }); + return banner; + } + + function clearTimer(entry) { + if (entry && entry.timer !== null) { + clearTimeout(entry.timer); + entry.timer = null; + } + } + + function reposition() { + let bottom = 20; + + for (const entry of [...banners.values()]) { + const banner = entry.element; + + if (!banner.classList.contains("is-visible")) continue; + + banner.style.bottom = `${bottom}px`; + bottom += banner.getBoundingClientRect().height + GAP; + } + } + + function hide(id = DEFAULT_ID) { + id = normalizeId(id); + const entry = banners.get(id); + if (!entry) return; + + clearTimer(entry); + entry.element.classList.remove("is-visible", "is-busy"); + reposition(); + } + + function show(message, options = {}) { + const id = normalizeId(options.id); + const banner = getBanner(id); + const entry = banners.get(id); + + clearTimer(entry); + + const type = options.type || "info"; + const busy = options.busy === true; + const sticky = options.sticky === true; + + const icons = { + info: "ℹ", + upload: "↑", + download: "↓", + success: "✓", + error: "!" + }; + + banner.classList.remove( + "is-info", "is-upload", "is-download", "is-success", "is-error" + ); + banner.classList.add("is-" + type); + banner.classList.toggle("is-busy", busy); + + banner.querySelector(".ob-icon").textContent = icons[type] || icons.info; + banner.querySelector(".ob-content").textContent = String(message || ""); + banner.classList.add("is-visible"); + + reposition(); + + if (!busy && !sticky) { + const duration = Number.isFinite(options.duration) + ? options.duration + : (type === "error" ? 6000 : 3500); + + entry.timer = setTimeout(() => hide(id), duration); + } + } + + function setProgress(id, value) { + id = normalizeId(id); + const banner = getBanner(id); + const percent = Math.max(0, Math.min(100, Number(value) || 0)); + + banner.classList.add("is-determinate"); + banner.classList.remove("is-busy"); + + banner.querySelector(".ob-progress-fill").style.width = `${percent}%`; + banner.querySelector(".ob-progress-label").textContent = `${Math.round(percent)}%`; + reposition(); + } + + window.operationBanner = { show, hide, setProgress }; +})(); diff --git a/public/js/optionsSheet.js b/public/js/optionsSheet.js new file mode 100644 index 0000000..a647c54 --- /dev/null +++ b/public/js/optionsSheet.js @@ -0,0 +1,220 @@ +// OPTIONS SHEET — Ordinamento, raggruppamento e filtri. +document.addEventListener("DOMContentLoaded", () => { + const optionsSheet = document.getElementById("optionsSheet"); + const sheetOverlay = document.getElementById("sheetOverlay"); + const optionsBtn = document.getElementById("optionsBtn"); + + if (!optionsSheet || !sheetOverlay || !optionsBtn) { + console.warn("[optionsSheet] Elementi del pannello non trovati."); + return; + } + + const searchInput = document.getElementById("gallerySearch"); + const yearSelect = document.getElementById("galleryYear"); + const monthSelect = document.getElementById("galleryMonth"); + const folderSelect = document.getElementById("galleryFolder"); + + function isPhotoMode() { + return window.state?.mode === "photos"; + } + + function updateSheetVisibility() { + optionsSheet.querySelectorAll("[data-photo-filter], [data-filter]") + .forEach((element) => { + element.style.display = + isPhotoMode() ? "" : "none"; + }); + } + + function populateYears(serverYears = null) { + if (!yearSelect) return; + + const previous = yearSelect.value; + const years = new Set(); + + if (Array.isArray(serverYears)) { + for (const value of serverYears) { + const year = Number(value); + if (Number.isInteger(year) && year >= 1000 && year <= 9999) { + years.add(year); + } + } + } else { + const items = window.getLocalItems?.() || []; + for (const item of items) { + if (!item?.taken_at) continue; + const date = new Date(item.taken_at); + if (!Number.isNaN(date.getTime())) years.add(date.getFullYear()); + } + } + + yearSelect.replaceChildren(); + + const all = document.createElement("option"); + all.value = ""; + all.textContent = "Tutti gli anni"; + yearSelect.appendChild(all); + + [...years].sort((a, b) => b - a).forEach(year => { + const option = document.createElement("option"); + option.value = String(year); + option.textContent = String(year); + yearSelect.appendChild(option); + }); + + yearSelect.value = years.has(Number(previous)) ? previous : ""; + } + function populateFolders(serverFolders = null) { + if (!folderSelect) return; + + const previous = window.currentFolder || ""; + const folders = new Set(); + + if (Array.isArray(serverFolders)) { + for (const value of serverFolders) { + const folder = String(value || "").trim(); + if (folder) folders.add(folder); + } + } else { + const items = window.getLocalItems?.() || []; + for (const item of items) { + const folder = String(item?.cartella || item?.folder || "").trim(); + if (folder) folders.add(folder); + } + } + + folderSelect.replaceChildren(); + + const all = document.createElement("option"); + all.value = ""; + all.textContent = "Tutte le cartelle"; + folderSelect.appendChild(all); + + [...folders].sort((a, b) => + a.localeCompare(b, "it", { sensitivity: "base" }) + ).forEach(folder => { + const option = document.createElement("option"); + option.value = folder; + option.textContent = folder; + folderSelect.appendChild(option); + }); + + folderSelect.value = folders.has(previous) ? previous : ""; + if (previous && !folders.has(previous)) window.currentFolder = ""; + } + async function openOptionsSheet() { + updateSheetVisibility(); + + let serverOptions = null; + if (isPhotoMode()) { + try { + const token = localStorage.getItem("token"); + const showDeleted = + document.getElementById("showDeleted")?.checked ? "1" : "0"; + + const response = await fetch( + `/photos/gallery/options?showDeleted=${showDeleted}`, + { headers: { Authorization: "Bearer " + token } } + ); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + serverOptions = await response.json(); + } catch (error) { + console.error("[optionsSheet] errore opzioni server:", error); + } + } + + populateYears( + Array.isArray(serverOptions?.years) ? serverOptions.years : null + ); + populateFolders( + Array.isArray(serverOptions?.folders) ? serverOptions.folders : null + ); + + if (searchInput) { + searchInput.value = window.currentSearch || ""; + } + if (yearSelect) { + yearSelect.value = window.currentYear || ""; + } + if (monthSelect) { + monthSelect.value = window.currentMonth || ""; + } + if (folderSelect) { + folderSelect.value = window.currentFolder || ""; + } + + optionsSheet.classList.add("open"); + sheetOverlay.classList.add("open"); + } + + function closeOptionsSheet() { + optionsSheet.classList.remove("open"); + sheetOverlay.classList.remove("open"); + } + + function resetFilters() { + window.currentFilter = ""; + window.currentSearch = ""; + window.currentYear = ""; + window.currentMonth = ""; + window.currentFolder = ""; + + if (searchInput) searchInput.value = ""; + if (yearSelect) yearSelect.value = ""; + if (monthSelect) monthSelect.value = ""; + if (folderSelect) folderSelect.value = ""; + + refreshGallery(); + } + + optionsSheet.addEventListener("click", (event) => { + const btn = event.target.closest(".sheet-btn"); + if (!btn) return; + + if (btn.hasAttribute("data-reset-filters")) { + resetFilters(); + closeOptionsSheet(); + return; + } + + const sort = btn.dataset.sort; + const group = btn.dataset.group; + const filter = btn.dataset.filter; + + if (sort) window.currentSort = sort; + if (group) window.currentGroup = group; + + if (filter !== undefined && isPhotoMode()) { + window.currentFilter = filter; + } + + refreshGallery(); + closeOptionsSheet(); + }); + + function updateSearchAndDate() { + if (!isPhotoMode()) return; + + window.currentSearch = searchInput?.value?.trim() || ""; + window.currentYear = yearSelect?.value || ""; + window.currentMonth = monthSelect?.value || ""; + window.currentFolder = folderSelect?.value || ""; + + refreshGallery(); + } + + // La ricerca viene applicata mentre si digita. + searchInput?.addEventListener("input", updateSearchAndDate); + yearSelect?.addEventListener("change", updateSearchAndDate); + monthSelect?.addEventListener("change", updateSearchAndDate); + folderSelect?.addEventListener("change", updateSearchAndDate); + + sheetOverlay.addEventListener("click", closeOptionsSheet); + optionsBtn.addEventListener("click", openOptionsSheet); + + updateSheetVisibility(); + + window.openOptionsSheet = openOptionsSheet; + window.closeOptionsSheet = closeOptionsSheet; +}); diff --git a/public/js/pageControls.js b/public/js/pageControls.js new file mode 100644 index 0000000..d4ec41c --- /dev/null +++ b/public/js/pageControls.js @@ -0,0 +1,541 @@ +// public/js/pageControls.js +// Controlli della pagina Galleria + +(() => { + "use strict"; + +function initializeProtectedPage() { + const authLoader = + document.getElementById( + "authLoader", + ); + + const app = + document.getElementById( + "app", + ); + + if (app) { + app.hidden = true; + + app.setAttribute( + "aria-hidden", + "true", + ); + } + + document.body.classList.remove( + "authenticated", + ); + + const isAuthenticated = + window.AppAuth && + typeof window.AppAuth + .isLoggedIn === + "function" && + window.AppAuth.isLoggedIn(); + + console.log( + "[AUTH CHECK]", + { + authenticated: + isAuthenticated, + + user: + window.AppAuth + ?.getUser?.(), + }, + ); + + if (!isAuthenticated) { + sessionStorage.removeItem( + "token", + ); + + window.location.replace( + "/login?redirect=/", + ); + + return false; + } + + document.body.classList.add( + "authenticated", + ); + + if (app) { + app.hidden = false; + + app.removeAttribute( + "aria-hidden", + ); + + app.style.removeProperty( + "display", + ); + } + + if (authLoader) { + authLoader.hidden = true; + + authLoader.style.display = + "none"; + } + + return true; +} + + function initializeConfigButton() { + const configButton = document.getElementById("configBtn"); + + if (!configButton) { + return; + } + + configButton.addEventListener("click", () => { + window.location.href = "/admin.html"; + }); + } + + function initializeModeToggle() { + const modeToggle = document.getElementById("modeToggle"); + +const leafletViews = { + photos: null, + activities: null, +}; + + if (!modeToggle) { + console.error("[modeToggle] Pulsante non trovato"); + return; + } + + function normalizeMode(mode) { + return mode === "activities" ? "activities" : "photos"; + } + + function updateModeUI(mode) { + const safeMode = normalizeMode(mode); + + document.body.classList.remove("photos", "activities"); + document.body.classList.add(safeMode); + + if (safeMode === "photos") { + modeToggle.textContent = "📷"; + modeToggle.setAttribute("aria-label", "Passa alle attività"); + modeToggle.title = "Passa alle attività"; + } else { + modeToggle.textContent = "🏃"; + modeToggle.setAttribute("aria-label", "Passa alle foto"); + modeToggle.title = "Passa alle foto"; + } + } + +modeToggle.addEventListener( + "click", + async () => { + const currentMode = + normalizeMode( + window.state?.mode, + ); + + const nextMode = + currentMode === "photos" + ? "activities" + : "photos"; + + const leafletMapDiv = + document.getElementById( + "globalMap", + ); + + const terrainMapDiv = + document.getElementById( + "terrainMap", + ); + + const terrainEnabled = + window.MapTerrain3D + ?.enabled?.() === true; + + const leafletOpen = + leafletMapDiv + ?.classList + .contains("open") === true; + + const terrainOpen = + terrainEnabled || + terrainMapDiv + ?.classList + .contains("open") === true; + + const leafletMap = + window.globalMap; + + if ( + leafletOpen && + !terrainEnabled && + leafletMap + ) { + const center = + leafletMap.getCenter?.(); + + const zoom = + leafletMap.getZoom?.(); + + if ( + center && + Number.isFinite( + center.lat, + ) && + Number.isFinite( + center.lng, + ) && + Number.isFinite( + zoom, + ) + ) { + leafletViews[ + currentMode + ] = { + lat: + center.lat, + + lng: + center.lng, + + zoom, + }; + } + } + + modeToggle.disabled = + true; + + try { + if (terrainOpen) { + if ( + currentMode === + "photos" + ) { + if ( + window.MapboxPhotoSpiderfy + ?.isOpen?.() + ) { + window.MapboxPhotoSpiderfy + ?.clear?.(); + } + + window.MapboxPhotoMarkers + ?.clear?.(); + + window.MapboxPhotoClusters + ?.clear?.(); + + window.MapboxMarkers + ?.setPhotos?.([]); + } else { + if ( + window.MapboxActivitySpiderfy + ?.isOpen?.() + ) { + window.MapboxActivitySpiderfy + ?.clear?.(); + } + + window.MapboxActivityMarkers + ?.clear?.(); + + window.MapboxActivityClusters + ?.clear?.(); + + window.MapboxMarkers + ?.setActivities?.([]); + + window.MapboxMarkers + ?.setRoutes?.([]); + } +} else if (leafletOpen) { +if ( + currentMode === + "photos" +) { + window.clearPhotoMapLayer?.( + true, + ); +} else { + window.clearActivityMapLayers?.(); +} +} + + window.closeBottomSheet?.(); + window.closeActivityModal?.(); + window.closeInfoPanel?.(); + + if ( + leafletOpen && + !terrainEnabled && + nextMode === "photos" && + leafletMap + ) { + const savedPhotoView = + leafletViews.photos; + + if (savedPhotoView) { + leafletMap.setView( + [ + savedPhotoView.lat, + savedPhotoView.lng, + ], + savedPhotoView.zoom, + { + animate: false, + }, + ); + } else { + leafletMap.setView( + [42.5, 12.5], + 6, + { + animate: false, + }, + ); + } + } + + // =============================== + // CAMBIA MODALITÀ + // =============================== + if ( + typeof window + .setGalleryMode === + "function" + ) { + window.setGalleryMode( + nextMode, + ); + } else { + window.state ??= {}; + + window.state.mode = + nextMode; + + window.loadLocalState?.(); + } + + updateModeUI( + nextMode, + ); + + // =============================== + // SINCRONIZZA I DATI + // =============================== + + if ( + typeof window + .progressiveSync === + "function" + ) { + await window.progressiveSync( + nextMode, + ); + } + + // Refresh eseguito da progressiveSync. + + // =============================== + // STATO MAPPE + // =============================== + + console.log( + "[modeToggle] aggiorno modalità", + { + currentMode, + nextMode, + leafletOpen, + terrainOpen, + terrainEnabled, + }, + ); + + + + // =============================== + // MAPBOX 3D + // =============================== + + if (terrainOpen) { + if ( + nextMode === + "activities" + ) { + window + .showActivityRoutesToggle + ?.(); + } else { + window + .hideActivityRoutesToggle + ?.( + true, + ); + } + + await window + .refreshTerrainMarkers?.(); + } + + + // =============================== + // LEAFLET + // =============================== + + else if (leafletOpen) { + if ( + nextMode === + "activities" + ) { + window.showActivityRoutesToggle?.(); + } else { + window.hideActivityRoutesToggle?.( + true, + ); + } + + window.globalMap + ?.invalidateSize?.(); + + await window + .redrawGlobalMap?.(); + } + + updateModeUI( + window.state?.mode || + nextMode, + ); + } catch (error) { + console.error( + "[modeToggle] Errore cambio modalità:", + error, + ); + + updateModeUI( + window.state?.mode || + "photos", + ); + } finally { + modeToggle.disabled = + false; + } + }, +); + + updateModeUI( + window.state?.mode || + "photos", + ); + } + +function initializeTerrainButton() { + const btn = document.getElementById("terrain3dBtn"); + + if (!btn) { + return; + } + + btn.addEventListener("click", async () => { + try { + await window.MapTerrain3D?.toggle(); + + btn.classList.toggle( + "active", + window.MapTerrain3D?.enabled?.() === true + ); + } catch (err) { + console.error("[Terrain3D]", err); + } + }); +} + +window.updateTerrainButton = function(enabled) { + const btn = document.getElementById("terrain3dBtn"); + if (!btn) return; + + btn.classList.toggle("active", enabled); + + btn.textContent = enabled ? "🗺️" : "🏔️"; + btn.title = enabled + ? "Torna alla mappa 2D" + : "Apri Terrain 3D"; +}; + + function initializeDeletedToggle() { + const toggleButton = document.getElementById("showDeletedBtn"); + + if (!toggleButton) { + console.error("[showDeleted] Pulsante occhio non trovato"); + return; + } + + function updateDeletedToggleUI(showDeleted) { + const eyeIcon = toggleButton.querySelector(".eye-icon"); + if (eyeIcon) { + eyeIcon.textContent = showDeleted ? "🙈" : "👁️"; + } + + toggleButton.classList.toggle("is-hidden", !showDeleted); + toggleButton.classList.toggle("is-visible", showDeleted); + toggleButton.setAttribute("aria-pressed", String(showDeleted)); + toggleButton.setAttribute( + "aria-label", + showDeleted + ? "Nascondi elementi cancellati" + : "Mostra elementi cancellati" + ); + toggleButton.title = showDeleted + ? "Elementi cancellati visibili" + : "Elementi cancellati nascosti"; + } + + toggleButton.addEventListener("click", () => { + const enabled = localStorage.getItem("showDeleted") !== "1"; + + document.dispatchEvent( + new CustomEvent("showDeletedChange", { + detail: { enabled } + }) + ); + + updateDeletedToggleUI(enabled); + }); + + updateDeletedToggleUI(localStorage.getItem("showDeleted") === "1"); + } + + function initializeEruda() { + if (window.eruda && typeof window.eruda.init === "function") { + window.eruda.init(); + } + } + + function initializePageControls() { + const authenticated = initializeProtectedPage(); + + if (!authenticated) { + return; + } + + initializeConfigButton(); + initializeModeToggle(); + initializeDeletedToggle(); + initializeTerrainButton(); + initializeEruda(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializePageControls, { + once: true, + }); + } else { + initializePageControls(); + } +})(); diff --git a/public/js/photoUpload.js b/public/js/photoUpload.js new file mode 100644 index 0000000..707782d --- /dev/null +++ b/public/js/photoUpload.js @@ -0,0 +1,384 @@ +(() => { + "use strict"; + + const button = document.getElementById("uploadPhotoBtn"); + + if (!button) { + console.warn("[photoUpload] Pulsante upload non trovato"); + return; + } + + let uploading = false; + + function authHeaders() { + const token = localStorage.getItem("token"); + return token ? { Authorization: `Bearer ${token}` } : {}; + } + + async function readResponse(response) { + const contentType = response.headers.get("content-type") || ""; + + if (contentType.includes("application/json")) { + return response.json(); + } + + const text = await response.text(); + throw new Error(text || `Errore HTTP ${response.status}`); + } + + async function api(url, options = {}) { + const response = await fetch(url, { + credentials: "include", + ...options, + headers: { + Accept: "application/json", + ...authHeaders(), + ...(options.headers || {}), + }, + }); + + const result = await readResponse(response); + + if (!response.ok || !result.ok) { + throw new Error(result.error || `Errore HTTP ${response.status}`); + } + + return result; + } + + function validFolder(value) { + return Boolean(value) && + value !== "." && + value !== ".." && + !value.includes("/") && + !value.includes("\\") && + !value.includes("\0"); + } + + function getStatus() { + let status = document.getElementById("photoUploadClientStatus"); + + if (!status) { + status = document.createElement("span"); + status.id = "photoUploadClientStatus"; + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + status.style.display = "none"; + document.body.appendChild(status); + } + + status.style.display = "none"; + return status; + } + + function chooseFile() { + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.multiple = true; + input.style.display = "none"; + document.body.appendChild(input); + + let finished = false; + + function finish(files) { + if (finished) return; + finished = true; + input.remove(); + window.removeEventListener("focus", onFocus); + resolve(files); + } + + function onFocus() { + setTimeout(() => { + if (!input.files?.length) finish([]); + }, 500); + } + + input.addEventListener("change", () => { + finish(Array.from(input.files || [])); + }, { once: true }); + + window.addEventListener("focus", onFocus, { once: true }); + input.click(); + }); + } + + function createDialog() { + const dialog = document.createElement("dialog"); + dialog.setAttribute("aria-labelledby", "photoUploadTitle"); + dialog.style.cssText = [ + "width:min(92vw,420px)", + "max-width:420px", + "border:0", + "border-radius:12px", + "padding:22px", + "box-shadow:0 8px 36px #0004", + "color:inherit", + ].join(";"); + + dialog.innerHTML = ` +
+

+ Carica una foto +

+ + + + + + + + + + + +
+ + +
+
+ `; + + document.body.appendChild(dialog); + + const form = dialog.querySelector("#photoUploadForm"); + const select = dialog.querySelector("#photoUploadFolder"); + const newBox = dialog.querySelector("#photoUploadNewFolderBox"); + const newInput = dialog.querySelector("#photoUploadNewFolder"); + const error = dialog.querySelector("#photoUploadDialogError"); + + select.addEventListener("change", () => { + const isNew = select.value === "__new__"; + newBox.hidden = !isNew; + newInput.required = isNew; + error.textContent = ""; + + if (isNew) newInput.focus(); + }); + + dialog.querySelector("#photoUploadCancel").addEventListener("click", () => { + dialog.close("cancel"); + }); + + dialog.addEventListener("click", (event) => { + if (event.target === dialog) dialog.close("cancel"); + }); + + return { + dialog, + form, + select, + newBox, + newInput, + error, + userInput: dialog.querySelector("#photoUploadUser"), + continueButton: dialog.querySelector("#photoUploadContinue"), + }; + } + + async function openUploadDialog() { + const targetsResult = await api("/photos/upload-targets"); + + // La destinazione è sempre l'utente autenticato, non un device. + const target = targetsResult.targets?.find( + (item) => item.type === "user" && + String(item.value).toLowerCase() === + String(targetsResult.user).toLowerCase() + ); + + if (!target) { + throw new Error("Non è disponibile una destinazione personale autorizzata."); + } + + const foldersResult = await api( + `/photos/upload-folders?source=${encodeURIComponent(target.value)}` + ); + + const ui = createDialog(); + ui.userInput.value = targetsResult.user; + + const newOption = document.createElement("option"); + newOption.value = "__new__"; + newOption.textContent = "+ Nuova cartella…"; + ui.select.appendChild(newOption); + + for (const folder of foldersResult.folders || []) { + const option = document.createElement("option"); + option.value = folder; + option.textContent = folder; + ui.select.appendChild(option); + } + + // Seleziona inizialmente la prima cartella esistente, se disponibile. + // La voce per creare una cartella resta sempre in cima. + if (foldersResult.folders?.length) { + ui.select.value = foldersResult.folders[0]; + } else { + ui.select.value = "__new__"; + ui.newBox.hidden = false; + ui.newInput.required = true; + } + + ui.dialog.showModal(); + + return new Promise((resolve, reject) => { + let finished = false; + + function finish(value) { + if (finished) return; + finished = true; + ui.dialog.close(); + ui.dialog.remove(); + resolve(value); + } + + ui.dialog.addEventListener("close", () => { + if (!finished) finish(null); + }, { once: true }); + + ui.form.addEventListener("submit", (event) => { + event.preventDefault(); + ui.error.textContent = ""; + + let folder = ui.select.value; + + if (folder === "__new__") { + folder = ui.newInput.value.trim(); + + if (!validFolder(folder)) { + ui.error.textContent = "Inserisci un nome di cartella valido, senza / o \\."; + ui.newInput.focus(); + return; + } + } + + if (!folder) { + ui.error.textContent = "Seleziona una cartella."; + return; + } + + finish({ source: target.value, folder }); + }); + + // Il reject resta disponibile per eventuali errori futuri nel dialogo. + void reject; + }); + } + + button.addEventListener("click", async () => { + if (uploading) return; + + const status = getStatus(); + const setStatus = (message) => { + status.textContent = message; + + if (window.operationBanner) { + const lower = String(message).toLowerCase(); + const error = lower.startsWith("errore:"); + const done = lower.includes("upload completato"); + const busy = !error && !done && + (lower.includes("carico") || lower.includes("caricamento")); + + window.operationBanner.show(message, { + type: error ? "error" : done ? "success" : "upload", + busy, + sticky: busy + }); + } + }; + + uploading = true; + button.disabled = true; + + try { + setStatus("Carico le cartelle..."); + const destination = await openUploadDialog(); + + if (!destination) { + setStatus("Upload annullato."); + return; + } + + setStatus("Seleziona una o più foto..."); + const files = await chooseFile(); + + if (!files.length) { + setStatus("Nessuna foto selezionata."); + return; + } + + const invalidFile = files.find( + file => file.type && !file.type.startsWith("image/") + ); + + if (invalidFile) { + throw new Error(`Il file "${invalidFile.name}" non è un'immagine.`); + } + + const formData = new FormData(); + formData.append("source", destination.source); + formData.append("folder", destination.folder); + + for (const file of files) { + formData.append("photo", file, file.name); + } + + setStatus(`Caricamento di ${files.length} foto...`); + + const result = await api("/photos/upload", { + method: "POST", + body: formData, + }); + + const uploadedCount = Array.isArray(result.files) + ? result.files.length + : files.length; + + setStatus( + `${uploadedCount} foto caricate. Attendo l'indicizzazione...` + ); + + // Mantiene invariato il comportamento precedente della galleria. + await new Promise((resolve) => setTimeout(resolve, 2000)); + + if (typeof window.refreshGallery === "function") { + window.refreshGallery(); + } + + setStatus( + "Upload completato. L'indicizzazione potrebbe richiedere qualche istante." + ); + + if (window.operationBanner) { + window.operationBanner.show( + "Upload completato. L'indicizzazione potrebbe richiedere qualche istante.", + { type: "success", busy: false, sticky: false, duration: 3500 } + ); + } + } catch (error) { + console.error("[photoUpload] Errore:", error); + setStatus(`Errore: ${error.message || "Upload non riuscito"}`); + } finally { + uploading = false; + button.disabled = false; + } + }); +})(); diff --git a/public/js/state.js b/public/js/state.js new file mode 100644 index 0000000..d337380 --- /dev/null +++ b/public/js/state.js @@ -0,0 +1,261 @@ +// =============================== +// state.js — Stato condiviso (foto + attività) +// =============================== + +// Modalità corrente: "photos" | "activities" +window.state = { + mode: "photos", // default + items: [], // contiene gli item della modalità corrente +}; + +// =============================== +// MODE HELPERS +// =============================== +function normalizeMode(mode) { + return mode === "activities" ? "activities" : "photos"; +} + +function cacheKeyForMode(mode) { + return normalizeMode(mode) === "photos" + ? "photosCache" + : "activitiesCache"; +} + +function lastSyncKeyForMode(mode) { + return normalizeMode(mode) === "photos" + ? "lastSyncPhotos" + : "lastSyncActivities"; +} + +// =============================== +// LAST SYNC +// =============================== +function getLastSync() { + return getLastSyncForMode(state.mode); +} + +function setLastSync(ts) { + setLastSyncForMode(state.mode, ts); +} + +function getLastSyncForMode(mode) { + return localStorage.getItem(lastSyncKeyForMode(mode)); +} + +function setLastSyncForMode(mode, ts) { + if (!ts) return; + localStorage.setItem(lastSyncKeyForMode(mode), ts); +} + +// =============================== +// GET / SET ITEMS +// =============================== +function getLocalItems() { + return state.items; +} + +function getLocalItemsForMode(mode) { + try { + const safeMode = normalizeMode(mode); + const raw = localStorage.getItem(cacheKeyForMode(safeMode)); + if (!raw) return []; + + const arr = JSON.parse(raw); + return Array.isArray(arr) ? arr : []; + } catch (e) { + console.warn("getLocalItemsForMode error:", e); + return []; + } +} + +function setLocalItems(arr) { + // Compatibilità con codice vecchio. + // Salva in base alla modalità corrente. + setLocalItemsForMode(state.mode, arr); +} + +function setLocalItemsForMode(mode, arr) { + const safeMode = normalizeMode(mode); + const safeArr = Array.isArray(arr) ? arr : []; + + try { + localStorage.setItem(cacheKeyForMode(safeMode), JSON.stringify(safeArr)); + + // Aggiorna state.items solo se sto salvando la modalità attualmente visibile + if (state.mode === safeMode) { + state.items = safeArr; + } + } catch (e) { + console.warn("setLocalItemsForMode error:", e); + } +} + +function setLocalPhotos(arr) { + setLocalItemsForMode("photos", arr); +} + +function setLocalActivities(arr) { + setLocalItemsForMode("activities", arr); +} + +// =============================== +// ADD / REMOVE ITEM +// =============================== +function addItemLocal(item) { + addItemLocalForMode(state.mode, item); +} + +function addItemLocalForMode(mode, item) { + if (!item || item.id == null) return; + + const safeMode = normalizeMode(mode); + const arr = getLocalItemsForMode(safeMode); + + const next = arr.filter(x => String(x.id) !== String(item.id)); + next.push(item); + + setLocalItemsForMode(safeMode, next); +} + +function removeItemLocal(id) { + removeItemLocalForMode(state.mode, id); +} + +function removeItemLocalForMode(mode, id) { + if (id == null) return; + + const safeMode = normalizeMode(mode); + const arr = getLocalItemsForMode(safeMode); + + const before = arr.length; + const next = arr.filter(x => String(x.id) !== String(id)); + const after = next.length; + + console.log("[removeItemLocalForMode]", { + mode: safeMode, + id, + before, + after, + }); + + setLocalItemsForMode(safeMode, next); +} + +function addPhotoLocal(item) { + addItemLocalForMode("photos", item); +} + +function addActivityLocal(item) { + addItemLocalForMode("activities", item); +} + +function removePhotoLocal(id) { + removeItemLocalForMode("photos", id); +} + +function removeActivityLocal(id) { + removeItemLocalForMode("activities", id); +} + +// =============================== +// LOCAL STORAGE CACHE +// =============================== +function saveLocalState() { + saveLocalStateForMode(state.mode); +} + +function saveLocalStateForMode(mode) { + const safeMode = normalizeMode(mode); + + try { + const arr = state.mode === safeMode + ? state.items + : getLocalItemsForMode(safeMode); + + localStorage.setItem(cacheKeyForMode(safeMode), JSON.stringify(arr || [])); + } catch (e) { + console.warn("saveLocalStateForMode error:", e); + } +} + +function savePhotosCache() { + saveLocalStateForMode("photos"); +} + +function saveActivitiesCache() { + saveLocalStateForMode("activities"); +} + +function loadLocalState() { + try { + const safeMode = normalizeMode(state.mode); + const arr = getLocalItemsForMode(safeMode); + + // Non filtrare qui deleted_at. + // Il filtro deve stare solo in gallery.js, + // perché dipende dal checkbox "Mostra eliminate". + state.items = arr; + + return state.items; + } catch (e) { + console.warn("loadLocalState error:", e); + } + + state.items = []; + return []; +} + + +// =============================== +// CAMBIO MODALITÀ GALLERY +// =============================== +function setGalleryMode(mode) { + const safeMode = normalizeMode(mode); + + state.mode = safeMode; + loadLocalState(); + + console.log("[setGalleryMode]", { + mode: state.mode, + items: getLocalItems().length, + sample: getLocalItems().slice(0, 3), + }); +} + +// =============================== +// EXPORT +// =============================== +window.normalizeMode = normalizeMode; + +window.getLastSync = getLastSync; +window.setLastSync = setLastSync; +window.getLastSyncForMode = getLastSyncForMode; +window.setLastSyncForMode = setLastSyncForMode; + +window.getLocalItems = getLocalItems; +window.setLocalItems = setLocalItems; + +window.getLocalItemsForMode = getLocalItemsForMode; +window.setLocalItemsForMode = setLocalItemsForMode; + +window.setLocalPhotos = setLocalPhotos; +window.setLocalActivities = setLocalActivities; + +window.addItemLocal = addItemLocal; +window.removeItemLocal = removeItemLocal; + +window.addItemLocalForMode = addItemLocalForMode; +window.removeItemLocalForMode = removeItemLocalForMode; + +window.addPhotoLocal = addPhotoLocal; +window.addActivityLocal = addActivityLocal; +window.removePhotoLocal = removePhotoLocal; +window.removeActivityLocal = removeActivityLocal; + +window.saveLocalState = saveLocalState; +window.saveLocalStateForMode = saveLocalStateForMode; +window.savePhotosCache = savePhotosCache; +window.saveActivitiesCache = saveActivitiesCache; + +window.loadLocalState = loadLocalState; +window.setGalleryMode = setGalleryMode; diff --git a/public/js/sync.js b/public/js/sync.js new file mode 100644 index 0000000..2924e70 --- /dev/null +++ b/public/js/sync.js @@ -0,0 +1,1009 @@ +// public/js/sync.js +// =============================== +// sync.js — Full load + Progressive Sync + WS +// Photos + Activities +// =============================== + +// WS config invariato +const WS_URL = "wss://prova-ws.patachina.it"; + +const RETENTION_DAYS = 30; +const RETENTION_MS = RETENTION_DAYS * 86400000; + +const WS_DORMANT_MS = 120000; +const WS_RECONNECT_DELAY_MS = 1500; +const WS_NEED_FULLSYNC_DELAY_MS = 800; + +const MAX_PROCESSED_EVENTS = 2000; + +const BATCH_SIZE = 200; +const FLUSH_DEBOUNCE_MS = 250; +const TOO_MANY_THRESHOLD = 1200; + +// =============================================== +// MODE / CACHE HELPERS — IMPORTANTI +// =============================================== + +function _normalizeMode(mode) { + return mode === "activities" ? "activities" : "photos"; +} + +function _cacheKeyForMode(mode) { + return _normalizeMode(mode) === "photos" + ? "photosCache" + : "activitiesCache"; +} + +function _lastSyncKeyForMode(mode) { + return _normalizeMode(mode) === "photos" + ? "lastSyncPhotos" + : "lastSyncActivities"; +} + +function getLocalItemsForMode(mode) { + const safeMode = _normalizeMode(mode); + + try { + const raw = localStorage.getItem(_cacheKeyForMode(safeMode)); + if (!raw) return []; + + const arr = JSON.parse(raw); + return Array.isArray(arr) ? arr : []; + } catch (e) { + console.warn("[getLocalItemsForMode] error:", e); + return []; + } +} + +function setLocalItemsForMode(mode, arr) { + const safeMode = _normalizeMode(mode); + const safeArr = Array.isArray(arr) ? arr : []; + + try { + localStorage.setItem(_cacheKeyForMode(safeMode), JSON.stringify(safeArr)); + + // Aggiorna state.items solo se sto salvando la modalità attualmente visibile + if (window.state && state.mode === safeMode) { + state.items = safeArr; + } + } catch (e) { + console.warn("[setLocalItemsForMode] error:", e); + } +} + +function setLocalPhotos(arr) { + setLocalItemsForMode("photos", arr); +} + +function setLocalActivities(arr) { + setLocalItemsForMode("activities", arr); +} + +function getLastSyncForMode(mode) { + return localStorage.getItem(_lastSyncKeyForMode(mode)); +} + +function setLastSyncForMode(mode, ts) { + if (!ts) return; + localStorage.setItem(_lastSyncKeyForMode(mode), ts); +} + +function removeLocalItemForMode(mode, id) { + if (!id) return; + + const safeMode = _normalizeMode(mode); + const arr = getLocalItemsForMode(safeMode); + const before = arr.length; + + const next = arr.filter(x => String(x.id) !== String(id)); + + setLocalItemsForMode(safeMode, next); + + console.log("[removeLocalItemForMode]", { + mode: safeMode, + id, + before, + after: next.length + }); +} + +function addLocalItemForMode(mode, item) { + if (!item || item.id == null) return; + + const safeMode = _normalizeMode(mode); + const arr = getLocalItemsForMode(safeMode); + + const next = arr.filter(x => String(x.id) !== String(item.id)); + next.push(item); + + setLocalItemsForMode(safeMode, next); +} + +function refreshIfVisible(mode) { + const safeMode = _normalizeMode(mode); + + if (window.state && state.mode === safeMode) { + if (typeof loadLocalState === "function") { + loadLocalState(); + } + + if (typeof refreshGallery === "function") { + refreshGallery(); + } + } +} + +// =============================================== +// SESSIONI E DEVICE ID +// =============================================== + +function getSessionId() { + let id = localStorage.getItem("ws_session_id"); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem("ws_session_id", id); + } + return id; +} + +function getDeviceId() { + let id = localStorage.getItem("ws_device_id"); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem("ws_device_id", id); + } + return id; +} + +function _ensureSessionId() { + return getSessionId(); +} + +// =============================================== +// RECOVERY DONE / SYNC DONE +// =============================================== + +let needRecoveryDoneAck = false; + +function _maybeSendRecoveryDone(ws) { + if (!needRecoveryDoneAck) return; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + console.log(">>> SENDING recovery_done"); + _send(ws, { type: "recovery_done" }); + needRecoveryDoneAck = false; +} + +async function sendSyncDone(session_id, mode = state.mode) { + const syncMode = _normalizeMode(mode); + + try { + console.log(">>> CALLING sync_done", { + session_id, + device_id: getDeviceId(), + mode: syncMode + }); + + const endpoint = + syncMode === "photos" + ? "/photos/sync_done" + : "/activities/sync_done"; + + await fetch(endpoint, { + method: "POST", + headers: { + ..._authHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + session_id, + device_id: getDeviceId(), + last_sync: Date.now(), + }), + }); + + console.log("📨 [WS] sync_done inviato", syncMode); + } catch (e) { + console.error("❌ [WS] Errore sync_done:", e); + } +} + +// =============================================== +// AUTH HEADERS +// =============================================== + +function _authHeaders() { + const token = localStorage.getItem("token"); + return { Authorization: "Bearer " + token }; +} + +// =============================================== +// API HELPERS — PER MODALITÀ +// =============================================== + +function _endpointBase(mode = state.mode) { + const syncMode = _normalizeMode(mode); + return syncMode === "photos" ? "/photos" : "/activities"; +} + +async function getAllItems(mode = state.mode) { + const syncMode = _normalizeMode(mode); + + const res = await fetch(_endpointBase(syncMode), { + headers: _authHeaders(), + }); + + return await res.json(); +} + +async function getChanges(since, mode = state.mode) { + const syncMode = _normalizeMode(mode); + + const res = await fetch( + `${_endpointBase(syncMode)}/changes?since=${encodeURIComponent(since)}`, + { headers: _authHeaders() } + ); + + return await res.json(); +} + +async function getDeletedHard(since, mode = state.mode) { + const syncMode = _normalizeMode(mode); + + const endpoint = + syncMode === "photos" + ? "/photos/deleted_hard" + : "/activities/deleted_hard"; + + const res = await fetch( + `${endpoint}?since=${encodeURIComponent(since)}`, + { headers: _authHeaders() } + ); + + const json = await res.json(); + + return json.deleted || []; +} + +async function fetchItemsByIds(ids, mode = state.mode) { + const syncMode = _normalizeMode(mode); + + if (!ids || !ids.length) return []; + + const qs = ids.map((id) => `id=${encodeURIComponent(id)}`).join("&"); + + const payload = parseJwt(localStorage.getItem("token") || ""); + const user = payload?.name || "Common"; + + const url = `${_endpointBase(syncMode)}/byIds?${qs}&user=${encodeURIComponent(user)}`; + + const res = await fetch(url, { + headers: _authHeaders(), + }); + + return await res.json(); +} + +// =============================================== +// TIME HELPERS +// =============================================== + +function _nowIso() { + return new Date().toISOString(); +} + +function _parseIsoMs(iso) { + const t = Date.parse(iso); + return Number.isFinite(t) ? t : 0; +} + +function _isTooOldForDelta(lastSyncIso) { + if (!lastSyncIso) return true; + + const lastMs = _parseIsoMs(lastSyncIso); + if (!lastMs) return true; + + return Date.now() - lastMs > RETENTION_MS; +} + +// =============================================== +// MAP UTILS +// =============================================== + +function _toMapById(arr) { + const m = new Map(); + + for (const p of arr || []) { + if (p && p.id != null) { + m.set(String(p.id), p); + } + } + + return m; +} + +// =============================================== +// FULL LOAD — SICURO PER MODALITÀ +// =============================================== + +async function fullLoad(mode = state.mode) { + const syncMode = _normalizeMode(mode); + + console.log("🟦 FULL LOAD → caricamento completo", syncMode); + + const items = await getAllItems(syncMode); + + console.log(`📥 FULL LOAD → ricevuti ${items.length} elementi (${syncMode})`); + + if (syncMode === "photos") { + setLocalPhotos(items); + } else { + setLocalActivities(items); + } + + refreshIfVisible(syncMode); + + const now = _nowIso(); + setLastSyncForMode(syncMode, now); + + console.log(`🕒 FULL LOAD → lastSync(${syncMode}) = ${now}`); +} + +// =============================================== +// PROGRESSIVE SYNC — SICURO PER MODALITÀ +// =============================================== + +async function progressiveSync(mode = state.mode) { + const syncMode = _normalizeMode(mode); + + if (window._syncInProgress) { + console.warn("⏳ progressiveSync già in corso"); + return; + } + + window._syncInProgress = true; + + try { + console.log("=============================================="); + console.log("🧪 [SYNC DEBUG] progressiveSync() chiamato"); + console.log("🚀 progressiveSync() START — mode:", syncMode); + + const lastSync = getLastSyncForMode(syncMode); + const localArr = getLocalItemsForMode(syncMode) || []; + + console.log(`🕒 lastSync(${syncMode}): ${lastSync}`); + console.log(`📦 Cache locale (${syncMode}): ${localArr.length}`); + + // Primo avvio / cache vuota → fullLoad + if (!lastSync || localArr.length === 0) { + console.warn("🟦 Cache locale vuota o lastSync mancante → fullLoad()"); + await fullLoad(syncMode); + console.log("🏁 progressiveSync() COMPLETATO con fullLoad"); + console.log("=============================================="); + return; + } + + // lastSync troppo vecchio → fullLoad + if (_isTooOldForDelta(lastSync)) { + console.warn(`🟥 lastSync > ${RETENTION_DAYS}gg → FULL LOAD richiesto`); + await fullLoad(syncMode); + console.log("🏁 progressiveSync() COMPLETATO con fullLoad: lastSync troppo vecchio"); + console.log("=============================================="); + return; + } + + console.log("🟩 PROGRESSIVE SYNC → changes + deleted_hard", syncMode); + + const changed = await getChanges(lastSync, syncMode); + console.log(`🟨 changes (${syncMode}): ${Array.isArray(changed) ? changed.length : 0}`); + + const hardDeleted = await getDeletedHard(lastSync, syncMode); + + console.log(`🟥 deleted_hard (${syncMode}): ${hardDeleted.length}`); + + const localMap = _toMapById(localArr); + + // Merge changed + if (Array.isArray(changed)) { + for (const p of changed) { + if (!p || p.id == null) continue; + localMap.set(String(p.id), p); + } + } + + // Hard delete solo per le foto + for (const d of hardDeleted) { + if (!d || d.id == null) continue; + localMap.delete(String(d.id)); + } + + const merged = Array.from(localMap.values()); + + if (syncMode === "photos") { + setLocalPhotos(merged); + } else { + setLocalActivities(merged); + } + + refreshIfVisible(syncMode); + + const now = _nowIso(); + setLastSyncForMode(syncMode, now); + + console.log(`🕒 Aggiorno lastSync(${syncMode}) → ${now}`); + console.log("🏁 progressiveSync() COMPLETATO"); + console.log("=============================================="); + } finally { + window._syncInProgress = false; + } +} + +// =============================================== +// PROGRESSIVE SYNC MIRATO DA since WS +// =============================================== + +async function progressiveSyncFrom(sinceIso, mode = state.mode) { + const syncMode = _normalizeMode(mode); + + if (!sinceIso) return progressiveSync(syncMode); + + console.log(`🟦 progressiveSyncFrom(${sinceIso}) — mode: ${syncMode}`); + + if (_isTooOldForDelta(sinceIso)) { + console.warn("🟥 since troppo vecchio → fullLoad()"); + await fullLoad(syncMode); + return; + } + + const localArr = getLocalItemsForMode(syncMode) || []; + + if (!localArr.length) { + await fullLoad(syncMode); + return; + } + + const changed = await getChanges(sinceIso, syncMode); + + const hardDeleted = await getDeletedHard(sinceIso, syncMode); + + const localMap = _toMapById(localArr); + + for (const p of changed || []) { + if (!p || p.id == null) continue; + localMap.set(String(p.id), p); + } + + for (const d of hardDeleted || []) { + if (!d || d.id == null) continue; + localMap.delete(String(d.id)); + } + + const merged = Array.from(localMap.values()); + + if (syncMode === "photos") { + setLocalPhotos(merged); + } else { + setLocalActivities(merged); + } + + refreshIfVisible(syncMode); + + setLastSyncForMode(syncMode, _nowIso()); +} + +// =============================================== +// PROCESSED EVENTS — Ring Buffer +// =============================================== + +function _loadProcessedRing() { + try { + const arr = JSON.parse(localStorage.getItem("processed_events") || "[]"); + if (!Array.isArray(arr)) return []; + return arr.slice(-MAX_PROCESSED_EVENTS); + } catch { + return []; + } +} + +function _saveProcessedRing(ring) { + localStorage.setItem( + "processed_events", + JSON.stringify(ring.slice(-MAX_PROCESSED_EVENTS)) + ); +} + +const processedRing = _loadProcessedRing(); +const processedSet = new Set(processedRing); + +function isProcessed(eventId) { + return !!eventId && processedSet.has(eventId); +} + +function markProcessed(eventId) { + if (!eventId) return; + if (processedSet.has(eventId)) return; + + processedRing.push(eventId); + processedSet.add(eventId); + + while (processedRing.length > MAX_PROCESSED_EVENTS) { + const old = processedRing.shift(); + if (old) processedSet.delete(old); + } + + _saveProcessedRing(processedRing); +} + +// =============================================== +// WS ADDED BURST HANDLER — PER MODALITÀ +// =============================================== + +let addedQueue = []; +let addedSet = new Set(); +let flushTimer = null; +let flushing = false; + +function enqueueAdded(id, mode = state.mode) { + if (!id) return; + + const syncMode = _normalizeMode(mode); + const key = `${syncMode}:${String(id)}`; + + if (addedSet.has(key)) return; + + addedSet.add(key); + addedQueue.push({ + id: String(id), + mode: syncMode + }); + + if (addedQueue.length >= TOO_MANY_THRESHOLD) { + console.warn( + `🟥 [WS] Burst added (${addedQueue.length}) → fallback progressiveSync()` + ); + + if (flushTimer) clearTimeout(flushTimer); + + flushTimer = null; + addedQueue = []; + addedSet.clear(); + + progressiveSync(syncMode).then(() => { + if (wsInstance && wsInstance.readyState === WebSocket.OPEN) { + _maybeSendRecoveryDone(wsInstance); + } + }); + + return; + } + + if (!flushTimer) { + flushTimer = setTimeout(() => { + flushTimer = null; + flushAddedQueue(); + }, FLUSH_DEBOUNCE_MS); + } +} + +async function flushAddedQueue() { + if (flushing) return; + if (addedQueue.length === 0) return; + + flushing = true; + + try { + const chunk = addedQueue.splice(0, BATCH_SIZE); + + for (const q of chunk) { + addedSet.delete(`${q.mode}:${q.id}`); + } + + // Raggruppo per modalità, così non mischio mai foto e attività + const byMode = { + photos: [], + activities: [] + }; + + for (const q of chunk) { + byMode[q.mode].push(q.id); + } + + for (const mode of ["photos", "activities"]) { + const ids = byMode[mode]; + if (!ids.length) continue; + + const items = await fetchItemsByIds(ids, mode); + + if (Array.isArray(items) && items.length) { + for (const item of items) { + addLocalItemForMode(mode, item); + } + + if (!window.applySyncedGalleryItems?.(mode, items)) { + refreshIfVisible(mode); + } + } + } + + if (addedQueue.length > 0) { + setTimeout(flushAddedQueue, 0); + } + } catch (e) { + console.error("❌ [WS] flushAddedQueue error:", e); + + const fallbackMode = + addedQueue[0]?.mode || + state.mode || + "photos"; + + addedQueue = []; + addedSet.clear(); + + await progressiveSync(fallbackMode); + + if (wsInstance && wsInstance.readyState === WebSocket.OPEN) { + _maybeSendRecoveryDone(wsInstance); + } + } finally { + flushing = false; + } +} + +// =============================================== +// WEBSOCKET REAL-TIME +// =============================================== + +let wsInstance = null; + +function _setLastSeenNow() { + localStorage.setItem("ws_last_seen", String(Date.now())); +} + +function _getLastSeen() { + return parseInt(localStorage.getItem("ws_last_seen") || "0", 10); +} + +function _safeJsonParse(raw) { + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function _send(ws, obj) { + try { + ws.send(JSON.stringify(obj)); + } catch (e) { + console.warn("⚠️ [WS] send failed:", e); + } +} + +function _ack(ws, event_id) { + if (!event_id) return; + _send(ws, { type: "ack", event_id }); +} + +// =============================================== +// START WEBSOCKET +// =============================================== + +function startWebSocket() { + console.log("🧪 [WS DEBUG] startWebSocket() chiamato"); + + const token = localStorage.getItem("token"); + if (!token) { + console.error("❌ [WS] Nessun token JWT trovato"); + return; + } + + if ( + wsInstance && + ( + wsInstance.readyState === WebSocket.OPEN || + wsInstance.readyState === WebSocket.CONNECTING + ) + ) { + console.log("⚠️ [WS] Connessione già attiva, ignoro startWebSocket()"); + return; + } + + if (wsInstance) { + try { + wsInstance.close(); + } catch {} + } + + const wsMode = _normalizeMode(state.mode); + + console.log("🔌 [WS] Creo nuova connessione WebSocket...", wsMode); + + wsInstance = new WebSocket(WS_URL); + + const ws = wsInstance; + const session_id = _ensureSessionId(); + const device_id = getDeviceId(); + + ws.onopen = () => { + console.log(`🟢 [WS OPEN] Connesso. session_id=${session_id}`); + console.log("[WS] device_id:", device_id); + console.log("[WS] mode:", wsMode); + + _send(ws, { + type: "auth", + token, + session_id, + device_id, + mode: wsMode, + }); + }; + + ws.onmessage = async (ev) => { + console.log("📩 [WS RAW] Messaggio ricevuto:", ev.data); + + _setLastSeenNow(); + + const msg = _safeJsonParse(ev.data); + if (!msg) { + console.error("❌ [WS] Errore parsing JSON"); + return; + } + + console.log("📩 [WS PARSED]:", msg); + + const eventMode = _normalizeMode(msg.mode || wsMode || state.mode); + + + // ----------------------------------------------- + // AUTH OK + // ----------------------------------------------- + if (msg.type === "auth_ok") { + console.log("🔐 WS autenticato come:", msg.user, "session:", msg.session_id); + + const needFullSync = msg.need_full_sync === true; + const needRecovery = needFullSync || msg.need_recovery === true; + + const lastSync = getLastSyncForMode(eventMode); + const localArr = getLocalItemsForMode(eventMode) || []; + const localCacheMissing = !lastSync || localArr.length === 0; + + console.log("🧪 [WS AUTH OK DECISION]", { + mode: eventMode, + need_full_sync: msg.need_full_sync, + need_recovery: msg.need_recovery, + needFullSync, + needRecovery, + reason: msg.reason, + lastSync, + localCount: localArr.length, + localCacheMissing, + }); + + if (needRecovery || localCacheMissing) { + if (needRecovery) { + console.warn("🟨 [WS] Server richiede recovery/progressive sync"); + needRecoveryDoneAck = true; + } else { + console.warn("🟦 [WS] Primo avvio o cache locale vuota → fullLoad tramite progressiveSync()"); + } + + setTimeout(async () => { + try { + console.log("🔄 [WS] Eseguo progressiveSync()", eventMode); + await progressiveSync(eventMode); + } catch (e) { + console.error("❌ [WS] Errore progressiveSync:", e); + } finally { + if (needRecovery) { + _maybeSendRecoveryDone(ws); + await sendSyncDone(msg.session_id, eventMode); + } + + _send(ws, { type: "client_recovered" }); + } + }, needFullSync ? WS_NEED_FULLSYNC_DELAY_MS : 0); + + return; + } + + console.log("🟢 [WS] Nessuna recovery richiesta → client_recovered"); + _send(ws, { type: "client_recovered" }); + return; + } + + // ----------------------------------------------- + // NEED RECOVERY + // ----------------------------------------------- + if (msg.type === "need_recovery") { + console.warn("🟨 [WS] need_recovery ricevuto:", msg.reason); + + needRecoveryDoneAck = true; + + try { + await progressiveSync(eventMode); + } catch (e) { + console.error("❌ [WS] Errore progressiveSync su need_recovery:", e); + } finally { + _maybeSendRecoveryDone(ws); + await sendSyncDone(session_id, eventMode); + _send(ws, { type: "client_recovered" }); + } + + return; + } + + // ----------------------------------------------- + // PING + // ----------------------------------------------- + if (msg.type === "ping") { + _send(ws, { type: "pong" }); + return; + } + + const event_id = msg.event_id; + + if (event_id && isProcessed(event_id)) { + console.log("♻️ [WS] Evento già processato, invio solo ACK:", event_id); + _ack(ws, event_id); + return; + } + + const finalize = () => { + if (event_id) { + markProcessed(event_id); + _ack(ws, event_id); + } + }; + + // ----------------------------------------------- + // BULK ADD_DIR + // ----------------------------------------------- + if (msg.type === "add_dir") { + if (msg.bulk === true) { + console.log( + `📦 [WS] add_dir bulk folder=${msg.folder} count=${msg.count} → progressiveSyncFrom(since)` + ); + + await progressiveSyncFrom(msg.since, eventMode); + } else { + console.log(`📁 [WS] add_dir folder=${msg.folder}`); + } + + finalize(); + return; + } + + // ----------------------------------------------- + // BULK DEL_DIR + // ----------------------------------------------- + if (msg.type === "del_dir") { + if (msg.mode === "bulk") { + console.log( + `📦 [WS] del_dir bulk folder=${msg.folder} count=${msg.count} → progressiveSyncFrom(since)` + ); + + await progressiveSyncFrom(msg.since, eventMode); + } else { + console.log(`📁 [WS] del_dir folder=${msg.folder}`); + } + + finalize(); + return; + } + + // ----------------------------------------------- + // ADDED + // ----------------------------------------------- + if (msg.type === "added") { + enqueueAdded(msg.id, eventMode); + finalize(); + return; + } + + // ----------------------------------------------- + // HARD DELETE / REMOVED + // ----------------------------------------------- + if (msg.type === "del" || msg.type === "removed") { + removeLocalItemForMode(eventMode, msg.id); + + if (!window.applySyncedGalleryItems?.(eventMode, [], msg.id)) { + refreshIfVisible(eventMode); + } + + finalize(); + return; + } + + // ----------------------------------------------- + // UPDATED + // ----------------------------------------------- + if (msg.type === "updated") { + const arr = getLocalItemsForMode(eventMode); + let updatedItem = arr.find( + p => String(p.id) === String(msg.id) + ); + + if (updatedItem) { + updatedItem.deleted_at = msg.deleted_at; + addLocalItemForMode(eventMode, updatedItem); + } else { + // Se non ho l'item locale, provo a recuperarlo. + const fetched = await fetchItemsByIds([msg.id], eventMode); + if (Array.isArray(fetched) && fetched[0]) { + updatedItem = fetched[0]; + addLocalItemForMode(eventMode, updatedItem); + } + } + + if (!window.applySyncedGalleryItems?.( + eventMode, + updatedItem ? [updatedItem] : [] + )) { + refreshIfVisible(eventMode); + } + + finalize(); + return; + } + + // ----------------------------------------------- + // DONE EVENTS + // ----------------------------------------------- + if (msg.type === "add_dir_done" || msg.type === "del_dir_done") { + console.log(`✅ [WS] ${msg.type} folder=${msg.folder} count=${msg.count}`); + finalize(); + return; + } + + console.log("ℹ️ [WS] Evento non gestito:", msg); + + if (event_id) { + console.log("ℹ️ [WS] Evento non gestito, ACK comunque:", event_id); + finalize(); + } + }; + + ws.onclose = () => { + console.warn(`❌ [WS CLOSE] Connessione chiusa. session_id=${session_id}`); + + if (wsInstance === ws) wsInstance = null; + + const now = Date.now(); + const lastSeen = _getLastSeen(); + + if (now - lastSeen < WS_DORMANT_MS) { + console.log("🔄 [WS] Tentativo di reconnect..."); + setTimeout(startWebSocket, WS_RECONNECT_DELAY_MS); + } else { + console.log("🟦 [WS] sessione dormiente → recovery al prossimo avvio"); + } + }; + + ws.onerror = (err) => { + console.error("⚠️ [WS ERROR]", err); + }; +} + +// =============================================== +// EXPORT UTILI +// =============================================== + +window.fullLoad = fullLoad; +window.progressiveSync = progressiveSync; +window.progressiveSyncFrom = progressiveSyncFrom; +window.startWebSocket = startWebSocket; + +window.getLocalItemsForMode = getLocalItemsForMode; +window.setLocalItemsForMode = setLocalItemsForMode; +window.setLocalPhotos = setLocalPhotos; +window.setLocalActivities = setLocalActivities; + +// =============================================== +// INIT +// =============================================== + +document.addEventListener("DOMContentLoaded", () => { + if (AppAuth.isLoggedIn()) { + startWebSocket(); + } +}); diff --git a/public/js/workerStatus.js b/public/js/workerStatus.js new file mode 100644 index 0000000..fa6d295 --- /dev/null +++ b/public/js/workerStatus.js @@ -0,0 +1,138 @@ +// Contatori di sola lettura delle code dei worker. +(() => { + "use strict"; + + const container = document.getElementById("workerStatus"); + if (!container) return; + + const queues = [ + { + key: "photos", + errorsKey: "photosErrors", + countId: "workerPhotosCount", + errorsId: "workerPhotosErrors", + label: "Acquisizione delle foto" + }, + { + key: "faces", + errorsKey: "facesErrors", + countId: "workerFacesCount", + errorsId: "workerFacesErrors", + label: "Riconoscimento facciale" + }, + { + key: "activities", + errorsKey: "activitiesErrors", + countId: "workerActivitiesCount", + errorsId: "workerActivitiesErrors", + label: "Elaborazione attività" + }, + { + key: "locations", + errorsKey: "locationsErrors", + countId: "workerLocationsCount", + errorsId: "workerLocationsErrors", + label: "Geolocalizzazione" + } + ].map(queue => ({ + ...queue, + count: document.getElementById(queue.countId), + errors: document.getElementById(queue.errorsId) + })); + + if (queues.some(q => !q.count || !q.errors)) return; + + let requestInProgress = false; + + function isAuthenticated() { + return document.body.classList.contains("authenticated") && + window.AppAuth && + typeof window.AppAuth.isLoggedIn === "function" && + window.AppAuth.isLoggedIn(); + } + + function number(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0; + } + + function formatCount(value) { + const n = number(value); + return n > 9999 ? "9999+" : String(n); + } + + async function refreshWorkerCounts() { + if (requestInProgress) return; + + if (!isAuthenticated() || !localStorage.getItem("token")) { + container.hidden = true; + return; + } + + requestInProgress = true; + + try { + const response = await fetch("/scanStatus/worker-queues", { + method: "GET", + cache: "no-store", + headers: { + Authorization: "Bearer " + localStorage.getItem("token"), + Accept: "application/json" + } + }); + + if (response.status === 401 || response.status === 403) { + container.hidden = true; + return; + } + if (!response.ok) throw new Error("HTTP " + response.status); + + const data = await response.json(); + if (!data.ok) throw new Error("Risposta non valida"); + + let hasJobsOrErrors = false; + + for (const q of queues) { + const jobs = number(data[q.key]); + const errors = number(data[q.errorsKey]); + const item = q.count.parentElement; + + q.count.textContent = formatCount(jobs); + q.errors.textContent = formatCount(errors) + " err."; + q.errors.hidden = errors === 0; + + item.classList.toggle("has-errors", errors > 0); + item.hidden = jobs === 0 && errors === 0; + item.title = `${q.label}: ${jobs} job da completare, ${errors} job in errore`; + item.setAttribute( + "aria-label", + `${q.label}: ${jobs} job da completare, ${errors} job in errore` + ); + + hasJobsOrErrors ||= jobs > 0 || errors > 0; + } + + container.hidden = !hasJobsOrErrors; + } catch (error) { + console.warn("[worker-status] Aggiornamento non riuscito:", error.message); + } finally { + requestInProgress = false; + } + } + + async function startWorkerStatus() { + let seconds = 1; + try { + const config = window.AppConfigPromise + ? await window.AppConfigPromise + : (window.AppConfig || {}); + const value = Number(config.workerStatusRefreshSeconds); + if (Number.isFinite(value) && value > 0) seconds = value; + } catch (error) { + console.warn("[worker-status] Uso intervallo predefinito:", error.message); + } + refreshWorkerCounts(); + window.setInterval(refreshWorkerCounts, seconds * 1000); + } + startWorkerStatus(); +})(); diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..0fcfa44 --- /dev/null +++ b/public/login.html @@ -0,0 +1,119 @@ + + + + + + Accedi + + + + + + + + + + + + + + + + + + diff --git a/public/logout.png b/public/logout.png new file mode 100644 index 0000000..bede7f7 Binary files /dev/null and b/public/logout.png differ diff --git a/public/vendor/rbush.min.js b/public/vendor/rbush.min.js new file mode 100644 index 0000000..1b1d708 --- /dev/null +++ b/public/vendor/rbush.min.js @@ -0,0 +1 @@ +!function(t,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(t="undefined"!=typeof globalThis?globalThis:t||self).RBush=i()}(this,(function(){"use strict";function t(e,h,r=0,s=e.length-1,a=n){for(;s>r;){if(s-r>600){const i=s-r+1,n=h-r+1,o=Math.log(i),l=.5*Math.exp(2*o/3),c=.5*Math.sqrt(o*l*(i-l)/i)*(n-i/2<0?-1:1);t(e,h,Math.max(r,Math.floor(h-n*l/i+c)),Math.min(s,Math.floor(h+(i-n)*l/i+c)),a)}const n=e[h];let o=r,l=s;for(i(e,r,h),a(e[s],n)>0&&i(e,r,s);o0;)l--}0===a(e[r],n)?i(e,r,l):(l++,i(e,l,s)),l<=h&&(r=l+1),h<=l&&(s=l-1)}}function i(t,i,n){const e=t[i];t[i]=t[n],t[n]=e}function n(t,i){return ti?1:0}function e(t,i,n){if(!n)return i.indexOf(t);for(let e=0;e=t.minX&&i.maxY>=t.minY}function d(t){return{children:t,height:1,leaf:!0,minX:1/0,minY:1/0,maxX:-1/0,maxY:-1/0}}function x(i,n,e,h,r){const s=[n,e];for(;s.length;){if((e=s.pop())-(n=s.pop())<=h)continue;const a=n+Math.ceil((e-n)/h/2)*h;t(i,a,n,e,r),s.push(n,a,a,e)}}return class{constructor(t=9){this._maxEntries=Math.max(4,t),this._minEntries=Math.max(2,Math.ceil(.4*this._maxEntries)),this.clear()}all(){return this._all(this.data,[])}search(t){let i=this.data;const n=[];if(!u(t,i))return n;const e=this.toBBox,h=[];for(;i;){for(let r=0;r=0&&h[i].children.length>this._maxEntries;)this._split(h,i),i--;this._adjustParentBBoxes(e,h,i)}_split(t,i){const n=t[i],e=n.children.length,r=this._minEntries;this._chooseSplitAxis(n,r,e);const s=this._chooseSplitIndex(n,r,e),a=d(n.children.splice(s,n.children.length-s));a.height=n.height,a.leaf=n.leaf,h(n,this.toBBox),h(a,this.toBBox),i?t[i-1].children.push(a):this._splitRoot(n,a)}_splitRoot(t,i){this.data=d([t,i]),this.data.height=t.height+1,this.data.leaf=!1,h(this.data,this.toBBox)}_chooseSplitIndex(t,i,n){let e,h=1/0,s=1/0;for(let a=i;a<=n-i;a++){const i=r(t,0,a,this.toBBox),o=r(t,a,n,this.toBBox),c=m(i,o),f=l(i)+l(o);c=i;e--){const i=t.children[e];s(o,t.leaf?h(i):i),l+=c(o)}return l}_adjustParentBBoxes(t,i,n){for(let e=n;e>=0;e--)s(i[e],t)}_condense(t){for(let i,n=t.length-1;n>=0;n--)0===t[n].children.length?n>0?(i=t[n-1].children,i.splice(i.indexOf(t[n]),1)):this.clear():h(t[n],this.toBBox)}}})); diff --git a/routes/activities.js b/routes/activities.js new file mode 100644 index 0000000..47f95ba --- /dev/null +++ b/routes/activities.js @@ -0,0 +1,1270 @@ +// routes/activities.js +const express = require("express"); +const router = express.Router(); + +const path = require("path"); +const fsp = require("fs/promises"); + +const db = require("../db/knex"); +const wss = require("../ws-server"); +const normalizeActivity = require("../helpers/normalizeActivity"); +const { + searchActivitiesInBounds, + invalidateActivitySpatialIndex, +} = require("../helpers/activitySpatialIndex"); +const { parseFitTrackPoints } = require("../api_v1/scanner/parsers/parseFit"); +const { + getUserAccess, + filterReadableSources, + canWrite, +} = require("../api_v1/userAccess"); + +// ----------------------------------------------------- +// File helpers hard delete attività +// ----------------------------------------------------- +const PUBLIC_ROOT = path.resolve(__dirname, "..", "public"); +const ACTIVITIES_ROOT = path.join(PUBLIC_ROOT, "activities"); +const DEL_ACTIVITIES_ROOT = path.join(PUBLIC_ROOT, "del_activities"); + +function getActivityReadAccess(req, res) { + const loggedUser = String(req.user?.name ?? "").trim(); + + if (!loggedUser) { + res.status(401).json({ + error: "Unauthorized", + }); + + return null; + } + + const access = getUserAccess(loggedUser); + + if (!access) { + res.status(403).json({ + error: "Utente non configurato", + user: loggedUser, + }); + + return null; + } + + return access; +} + +function safeJoin(base, ...parts) { + const resolvedBase = path.resolve(base); + const resolved = path.resolve(base, ...parts); + + const isInsideBase = + resolved === resolvedBase || + resolved.startsWith(`${resolvedBase}${path.sep}`); + + if (!isInsideBase) { + throw new Error(`Path non sicuro: ${resolved}`); + } + + return resolved; +} + +function getOriginalActivityPath(row) { + return safeJoin( + ACTIVITIES_ROOT, + row.user, + "original", + row.cartella || "", + row.file_name, + ); +} + +function getDeletedActivityPath(row) { + return safeJoin( + DEL_ACTIVITIES_ROOT, + row.user, + "original", + row.cartella || "", + row.file_name, + ); +} + +async function pathExists(filePath) { + try { + await fsp.access(filePath); + return true; + } catch { + return false; + } +} + +async function moveFileSafe(src, dst) { + if (!src || !dst) return false; + + const exists = await pathExists(src); + + if (!exists) { + console.warn(`⚠️ File attività non trovato, skip move: ${src}`); + return false; + } + + // Crea automaticamente public/del_activities//original// + await fsp.mkdir(path.dirname(dst), { recursive: true }); + + try { + await fsp.rename(src, dst); + return true; + } catch (err) { + // fallback se source/destination sono su filesystem diversi + if (err.code === "EXDEV") { + await fsp.copyFile(src, dst); + await fsp.unlink(src); + return true; + } + + throw err; + } +} + +// ----------------------------------------------------- +// POST /activities +// Salva attività inviata dallo scanner locale +// ----------------------------------------------------- +router.post("/", async (req, res) => { + const a = req.body; + const start = Date.now(); + + console.log(`📥 [POST /activities] Richiesta at=${new Date().toISOString()}`); + console.log("📥 Body:", a); + + try { + const loggedUser = String(req.user?.name ?? "").trim(); + const targetSource = String(a?.user ?? "").trim(); + + if (!loggedUser) { + return res.status(401).json({ + ok: false, + error: "Unauthorized", + }); + } + + const access = getUserAccess(loggedUser); + + if (!access) { + return res.status(403).json({ + ok: false, + error: "Utente non configurato", + user: loggedUser, + }); + } + + if (!targetSource) { + return res.status(400).json({ + ok: false, + error: "Missing activity user/source", + }); + } + + if (!canWrite(loggedUser, targetSource)) { + console.warn("⛔ Inserimento attività non autorizzato", { + loggedUser, + targetSource, + id: a?.id, + }); + + return res.status(403).json({ + ok: false, + error: "Write not allowed", + targetSource, + }); + } + + console.log( + `🏃 [DB INSERT] Salvo activity id=${a.id} user=${targetSource}`, + ); + + const now = new Date().toISOString(); + + await db("activities") + .insert({ + // Mantieni qui tutti i campi attuali + id: a.id, + user: targetSource, + cartella: a.cartella, + + file_name: a.file_name, + file_extension: a.file_extension, + format: a.format, + display_name: a.display_name, + route_name: a.route_name, + title: a.title, + description: a.description, + notes: a.notes, + sport: a.sport, + activity_type: a.activity_type, + device_manufacturer: a.device_manufacturer, + device_model: a.device_model, + source_app: a.source_app, + + start_time_millis: a.start_time_millis, + end_time_millis: a.end_time_millis, + duration_millis: a.duration_millis, + + distance_meters: a.distance_meters, + elevation_gain_meters: a.elevation_gain_meters, + elevation_loss_meters: a.elevation_loss_meters, + + min_elevation_meters: a.min_elevation_meters, + max_elevation_meters: a.max_elevation_meters, + + file_distance_meters: a.file_distance_meters, + file_elevation_gain_meters: a.file_elevation_gain_meters, + + computed_distance_meters: a.computed_distance_meters, + computed_elevation_gain_meters: a.computed_elevation_gain_meters, + + average_speed_mps: a.average_speed_mps, + max_speed_mps: a.max_speed_mps, + average_pace_sec_per_km: a.average_pace_sec_per_km, + + average_heart_rate: a.average_heart_rate, + max_heart_rate: a.max_heart_rate, + average_cadence: a.average_cadence, + max_cadence: a.max_cadence, + + calories: a.calories, + power_avg: a.power_avg, + power_max: a.power_max, + + point_count: a.point_count, + segment_count: a.segment_count, + lap_count: a.lap_count, + + min_latitude: a.min_latitude, + max_latitude: a.max_latitude, + min_longitude: a.min_longitude, + max_longitude: a.max_longitude, + center_latitude: a.center_latitude, + center_longitude: a.center_longitude, + + size_bytes: a.size_bytes, + last_modified_millis: a.last_modified_millis, + date_added_millis: a.date_added_millis, + date_modified_millis: a.date_modified_millis, + last_scan_millis: a.last_scan_millis, + + is_favorite: a.is_favorite, + is_hidden: a.is_hidden, + rating: a.rating, + + tags: a.tags ? JSON.stringify(a.tags) : null, + track_preview: a.track_preview ? JSON.stringify(a.track_preview) : null, + + scan_status: a.scan_status, + scan_error: a.scan_error, + diagnostics: a.diagnostics ? JSON.stringify(a.diagnostics) : null, + location: a.location ? JSON.stringify(a.location) : null, + + path: a.path, + fast_hash: a.fast_hash, + deleted_at: a.deleted_at || null, + + created_at: now, + updated_at: now, + }) + .onConflict("id") + .merge(); + + invalidateActivitySpatialIndex(); + + wss.broadcastToUser(targetSource, { + type: "added", + id: a.id, + entity: "activity", + }); + + console.log( + `✅ [POST /activities] Activity id=${a.id} salvata ` + + `durata=${Date.now() - start}ms`, + ); + + return res.json({ + ok: true, + }); + } catch (err) { + console.error("❌ Errore POST /activities:", err); + + return res.status(500).json({ + ok: false, + error: err.message, + }); + } +}); +// ----------------------------------------------------- +// GET /activities/changes +// Progressive sync +// ----------------------------------------------------- +router.get("/changes", async (req, res) => { + const access = getActivityReadAccess(req, res); + + if (!access) { + return; + } + + const since = req.query.since; + const start = Date.now(); + + console.log( + `🔄 [SYNC SINCE] GET /activities/changes ` + + `user=${access.name} since=${since} ` + + `at=${new Date().toISOString()}`, + ); + + console.log("🔐 Sorgenti leggibili:", access.readableSources); + + if (!since) { + return res.status(400).json({ + error: "Missing ?since=timestamp", + }); + } + + if (access.readableSources.length === 0) { + return res.json([]); + } + + try { + const rows = await db("activities") + .whereIn("user", access.readableSources) + .andWhere((builder) => { + builder + .where("created_at", ">", since) + .orWhere("updated_at", ">", since); + }) + .orderBy("updated_at", "desc"); + + console.log( + `🔄 [SYNC SINCE] Risposta: ${rows.length} attività ` + + `durata=${Date.now() - start}ms`, + ); + + return res.json(rows.map(normalizeActivity)); + } catch (err) { + console.error("❌ Errore GET /activities/changes:", err); + + return res.status(500).json({ + error: err.message, + }); + } +}); + +// ----------------------------------------------------- +// GET /activities/byIds +// ----------------------------------------------------- +router.get("/byIds", async (req, res) => { + const access = getActivityReadAccess(req, res); + + if (!access) { + return; + } + + const ids = req.query.id; + const requestedUsers = req.query.user; + const start = Date.now(); + + console.log(`📤 [GET /activities/byIds] at=${new Date().toISOString()}`); + console.log("📤 ids:", ids); + console.log("📤 requestedUsers:", requestedUsers); + console.log("🔐 readableSources:", access.readableSources); + + if (!ids) { + return res.json([]); + } + + const idList = Array.isArray(ids) ? ids : [ids]; + + const allowedSources = filterReadableSources(access.name, requestedUsers); + + console.log("🔐 allowedSources:", allowedSources); + + if (allowedSources.length === 0) { + return res.json([]); + } + + try { + const rows = await db("activities") + .whereIn("id", idList) + .whereIn("user", allowedSources); + + console.log( + `📤 [GET /activities/byIds] trovate ${rows.length} attività ` + + `durata=${Date.now() - start}ms`, + ); + + return res.json(rows.map(normalizeActivity)); + } catch (err) { + console.error("❌ Errore GET /activities/byIds:", err); + + return res.status(500).json({ + error: err.message, + }); + } +}); + +// ----------------------------------------------------- + +// ----------------------------------------------------- + +// ----------------------------------------------------- +// GET /activities/map +// Restituisce soltanto le attività che intersecano l'area visibile. +// Non carica i file originali: restituisce metadati e track_preview. +// ----------------------------------------------------- +router.get("/map", async (req, res) => { + const access = getActivityReadAccess(req, res); + if (!access) return; + + if (!access.readableSources.length) { + return res.json({ items: [], limit: 1500, truncated: false }); + } + + const west = Number(req.query.west); + const east = Number(req.query.east); + const south = Number(req.query.south); + const north = Number(req.query.north); + + if ( + ![west, east, south, north].every(Number.isFinite) || + west < -180 || west > 180 || + east < -180 || east > 180 || + south < -90 || north > 90 || + south > north + ) { + return res.status(400).json({ + error: "Limiti geografici non validi", + }); + } + + const requestedLimit = Number.parseInt(req.query.limit, 10); + const limit = Number.isFinite(requestedLimit) + ? Math.max(1, Math.min(requestedLimit, 2500)) + : 1500; + + const includeDeleted = ["1", "true", "yes"].includes( + String(req.query.showDeleted || "").toLowerCase() + ); + + try { + const { items: page, truncated } = + await searchActivitiesInBounds({ + west, + east, + south, + north, + readableSources: access.readableSources, + includeDeleted, + limit, + }); + + return res.json({ + items: page.map(normalizeActivity), + limit, + truncated, + }); + } catch (err) { + console.error("Errore GET /activities/map:", err); + return res.status(500).json({ + error: "Errore caricamento attività della mappa", + }); + } +}); + +// GET /activities/gallery +// Paginazione dedicata alla galleria; non modifica il full sync. +// ----------------------------------------------------- +router.get("/gallery", async (req, res) => { + const access = getActivityReadAccess(req, res); + if (!access) return; + + if (!access.readableSources.length) { + return res.json({ + items: [], + nextCursor: null, + hasMore: false, + }); + } + + try { + const requestedLimit = Number.parseInt(req.query.limit, 10); + const limit = Number.isFinite(requestedLimit) + ? Math.max(1, Math.min(requestedLimit, 100)) + : 40; + + const sort = String(req.query.sort || "desc").toLowerCase() === "asc" + ? "asc" + : "desc"; + + let cursor = null; + const cursorText = String(req.query.cursor || ""); + + if (cursorText) { + try { + const decoded = JSON.parse( + Buffer.from(cursorText, "base64url").toString("utf8") + ); + + if ( + !decoded || + !Number.isFinite(decoded.startTime) || + typeof decoded.id !== "string" || + !decoded.id + ) { + return res.status(400).json({ + error: "Cursore non valido", + }); + } + + cursor = decoded; + } catch { + return res.status(400).json({ + error: "Cursore non valido", + }); + } + } + + let query = db("activities") + .whereIn("user", access.readableSources); + + const includeDeleted = ["1", "true", "yes"].includes( + String(req.query.showDeleted || "").toLowerCase() + ); + + if (!includeDeleted) { + query = query.whereNull("deleted_at"); + } + + // Ordinamento stabile: data di inizio, poi ID. + // COALESCE include anche attività senza data. + if (cursor) { + const comparison = sort === "asc" ? ">" : "<"; + + query = query.where(function () { + this.whereRaw( + `COALESCE(start_time_millis, 0) ${comparison} ?`, + [cursor.startTime] + ).orWhere(function () { + this.whereRaw( + "COALESCE(start_time_millis, 0) = ?", + [cursor.startTime] + ).andWhere("id", comparison, cursor.id); + }); + }); + } + + const direction = sort === "asc" ? "ASC" : "DESC"; + const rows = await query + .orderByRaw(`COALESCE(start_time_millis, 0) ${direction}`) + .orderBy("id", sort) + .limit(limit + 1); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + + let nextCursor = null; + + if (hasMore && page.length) { + const last = page[page.length - 1]; + + nextCursor = Buffer.from(JSON.stringify({ + startTime: Number(last.start_time_millis) || 0, + id: String(last.id), + })).toString("base64url"); + } + + return res.json({ + items: page.map(normalizeActivity), + nextCursor, + hasMore, + limit, + }); + } catch (err) { + console.error("Errore GET /activities/gallery:", err); + + return res.status(500).json({ + error: "Errore caricamento galleria attività", + }); + } +}); + +// GET /activities +// Full sync +// ----------------------------------------------------- +router.get("/", async (req, res) => { + const access = getActivityReadAccess(req, res); + + if (!access) { + return; + } + + const start = Date.now(); + + console.log( + `📤 [FULL SYNC] GET /activities da=${access.name} ` + + `at=${new Date().toISOString()}`, + ); + + console.log("🔐 Sorgenti leggibili:", access.readableSources); + + if (access.readableSources.length === 0) { + return res.json([]); + } + + try { + const rows = await db("activities") + .whereIn("user", access.readableSources) + .orderBy("start_time_millis", "desc"); + + console.log( + `📤 [FULL SYNC] Risposta: ${rows.length} attività ` + + `durata=${Date.now() - start}ms`, + ); + + return res.json(rows.map(normalizeActivity)); + } catch (err) { + console.error("❌ Errore GET /activities:", err); + + return res.status(500).json({ + error: err.message, + }); + } +}); + +// ----------------------------------------------------- +// POST /activities/initDB +// Reset DB attività +// - Admin: cancella tutte le attività +// - Utente normale: cancella solo le proprie +// ----------------------------------------------------- +router.post("/initDB", async (req, res) => { + const loggedUser = String(req.user?.name ?? "").trim(); + const start = Date.now(); + + try { + if (!loggedUser) { + return res.status(401).json({ + ok: false, + error: "Unauthorized", + }); + } + + const access = getUserAccess(loggedUser); + + if (!access) { + return res.status(403).json({ + ok: false, + error: "Utente non configurato", + user: loggedUser, + }); + } + + const writableSources = access.writableSources; + + console.log("🧹 [POST /activities/initDB]", { + requestedBy: loggedUser, + writableSources, + }); + + if (writableSources.length === 0) { + return res.json({ + ok: true, + scope: "none", + user: loggedUser, + writableSources: [], + deletedActivities: 0, + deletedHardRecords: 0, + deletedSyncRecords: 0, + duration_ms: Date.now() - start, + message: "Nessuna sorgente scrivibile da svuotare", + }); + } + + const result = await db.transaction(async (trx) => { + const deletedActivities = await trx("activities") + .whereIn("user", writableSources) + .del(); + + const deletedHardRecords = await trx("deleted_hard_activities") + .whereIn("user", writableSources) + .del(); + + /* + * sync_done_activities non contiene attualmente la colonna user. + * Possiamo limitarlo ai device scrivibili usando device_id. + */ + const deletedSyncRecords = await trx("sync_done_activities") + .whereIn("device_id", writableSources) + .del(); + + return { + deletedActivities, + deletedHardRecords, + deletedSyncRecords, + }; + }); + + invalidateActivitySpatialIndex(); + + return res.json({ + ok: true, + scope: "writableSources", + user: loggedUser, + writableSources, + ...result, + duration_ms: Date.now() - start, + message: + `DB attività svuotato per le sorgenti scrivibili ` + `di ${loggedUser}`, + }); + } catch (err) { + console.error("❌ Errore POST /activities/initDB:", err); + + return res.status(500).json({ + ok: false, + error: err.message, + duration_ms: Date.now() - start, + }); + } +}); + +// ----------------------------------------------------- +// POST /activities/initDBuser +// Reset DB attività per utente specifico +// Solo Admin +// ----------------------------------------------------- +router.post("/initDBuser", async (req, res) => { + try { + const loggedUser = String(req.user?.name ?? "").trim(); + + if (loggedUser !== "Admin") { + return res.status(403).json({ + ok: false, + error: "Forbidden", + loggedUser, + }); + } + + const targetUser = String( + req.body?.user || + req.query?.user || + req.body?.name || + req.query?.name || + "", + ).trim(); + + if (!targetUser) { + return res.status(400).json({ + ok: false, + error: "Missing user", + }); + } + + console.log( + `🧹 [POST /activities/initDBuser] ` + + `Admin=${loggedUser} target=${targetUser}`, + ); + + const result = await db.transaction(async (trx) => { + const deletedActivities = await trx("activities") + .where({ user: targetUser }) + .del(); + + const deletedHardRecords = await trx("deleted_hard_activities") + .where({ user: targetUser }) + .del(); + + const deletedSyncRecords = await trx("sync_done_activities") + .where({ device_id: targetUser }) + .del(); + + return { + deletedActivities, + deletedHardRecords, + deletedSyncRecords, + }; + }); + + invalidateActivitySpatialIndex(); + + return res.json({ + ok: true, + user: targetUser, + ...result, + message: `DB attività svuotato per user=${targetUser}`, + }); + } catch (err) { + console.error("❌ Errore POST /activities/initDBuser:", err); + + return res.status(500).json({ + ok: false, + error: err.message, + }); + } +}); +// ----------------------------------------------------- +// GET /activities/deleted_hard +// Deve stare PRIMA di /:id +// ----------------------------------------------------- +router.get("/deleted_hard", async (req, res) => { + const access = getActivityReadAccess(req, res); + + if (!access) { + return; + } + + const since = req.query.since; + + if (!since) { + return res.status(400).json({ + error: "Missing ?since=timestamp", + }); + } + + if (access.readableSources.length === 0) { + return res.json({ + deleted: [], + }); + } + + try { + const rows = await db("deleted_hard_activities") + .whereIn("user", access.readableSources) + .andWhere("deleted_at", ">", since) + .orderBy("deleted_at", "asc"); + + return res.json({ + deleted: rows, + }); + } catch (err) { + console.error("❌ Errore GET /activities/deleted_hard:", err); + + return res.status(500).json({ + error: err.message, + }); + } +}); + +// ----------------------------------------------------- +// POST /activities/sync_done +// ----------------------------------------------------- +router.post("/sync_done", async (req, res) => { + try { + const loggedUser = String(req.user?.name ?? "").trim(); + const deviceId = String(req.body?.device_id ?? "").trim(); + const sessionId = String(req.body?.session_id ?? "").trim(); + + if (!loggedUser) { + return res.status(401).json({ + ok: false, + error: "Unauthorized", + }); + } + + if (!getUserAccess(loggedUser)) { + return res.status(403).json({ + ok: false, + error: "Utente non configurato", + user: loggedUser, + }); + } + + if (!deviceId) { + return res.status(400).json({ + ok: false, + error: "Missing device_id", + }); + } + + if (!sessionId) { + return res.status(400).json({ + ok: false, + error: "Missing session_id", + }); + } + + if (!canWrite(loggedUser, deviceId)) { + return res.status(403).json({ + ok: false, + error: "Write not allowed", + device_id: deviceId, + }); + } + + await db("sync_done_activities").insert({ + session_id: sessionId, + device_id: deviceId, + last_sync: req.body.last_sync, + }); + + return res.json({ + ok: true, + }); + } catch (err) { + return res.status(500).json({ + ok: false, + error: err.message, + }); + } +}); + +// ----------------------------------------------------- +// POST /activities/:id/toggle-soft +// Toggle soft delete attività per ID +// - Se deleted_at è null → soft delete +// - Se deleted_at è valorizzato → restore +// ----------------------------------------------------- +router.post("/:id/toggle-soft", async (req, res) => { + const id = req.params.id; + const loggedUser = String(req.user?.name ?? "").trim(); + const start = Date.now(); + + try { + if (!loggedUser) { + return res.status(401).json({ + ok: false, + error: "Unauthorized", + }); + } + + console.log( + `🔁 [TOGGLE SOFT /activities/${id}] ` + `richiesto da=${loggedUser}`, + ); + + const row = await db("activities").where({ id }).first(); + + if (!row || !canWrite(loggedUser, row.user)) { + return res.status(404).json({ + ok: false, + error: "Activity not found or not allowed", + id, + }); + } + + const now = new Date().toISOString(); + const nowMs = Date.now(); + const nextDeletedAt = row.deleted_at ? null : now; + + await db("activities").where({ id }).update({ + deleted_at: nextDeletedAt, + updated_at: now, + date_modified_millis: nowMs, + }); + + invalidateActivitySpatialIndex(); + + try { + wss.broadcastToUser(row.user, { + type: "updated", + id, + entity: "activity", + deleted_at: nextDeletedAt, + }); + } catch (err) { + console.warn( + "⚠️ WS broadcast activity toggle soft fallito:", + err.message, + ); + } + + return res.json({ + ok: true, + id, + user: row.user, + deleted_at: nextDeletedAt, + soft_deleted: Boolean(nextDeletedAt), + restored: !nextDeletedAt, + duration_ms: Date.now() - start, + }); + } catch (err) { + console.error("❌ Errore POST /activities/:id/toggle-soft:", err); + + return res.status(500).json({ + ok: false, + error: err.message, + }); + } +}); + +// ----------------------------------------------------- +// DELETE /activities/:id +// HARD DELETE attività per ID +// - Sposta originale in public/del_activities//original// +// - Rimuove record da activities +// - Registra ID in deleted_hard_activities +// ----------------------------------------------------- +router.delete("/:id", async (req, res) => { + const id = req.params.id; + const loggedUser = req.user?.name; + const start = Date.now(); + + try { + if (!loggedUser) { + return res.status(401).json({ + ok: false, + error: "Unauthorized", + }); + } + + console.log( + `🗑️ [HARD DELETE /activities/${id}] richiesto da=${loggedUser}`, + ); + + const row = await db("activities").where({ id }).first(); + + if (!row || !canWrite(loggedUser, row.user)) { + return res.status(404).json({ + ok: false, + error: "Activity not found or not allowed", + id, + }); + } + + if (!row) { + return res.status(404).json({ + ok: false, + error: "Activity not found or not allowed", + id, + }); + } + + const deletedAt = new Date().toISOString(); + + // 1. Sposta file originale attività + const originalSrc = getOriginalActivityPath(row); + const originalDst = getDeletedActivityPath(row); + const originalMoved = await moveFileSafe(originalSrc, originalDst); + + // 2. Elimina record da activities + await db("activities").where({ id }).del(); + + invalidateActivitySpatialIndex(); + + // 3. Registra hard delete + await db("deleted_hard_activities") + .insert({ + id, + user: row.user, + deleted_at: deletedAt, + }) + .onConflict("id") + .merge(); + + // 4. WS realtime + try { + wss.broadcastToUser(row.user, { + type: "removed", + id, + entity: "activity", + }); + } catch (e) { + console.warn("⚠️ WS broadcast activity hard delete fallito:", e.message); + } + + console.log( + `✅ [HARD DELETE /activities/${id}] user=${row.user} durata=${Date.now() - start}ms`, + ); + + res.json({ + ok: true, + id, + user: row.user, + deleted_at: deletedAt, + hard_deleted: true, + files: { + original_src: originalSrc, + original_dst: originalDst, + original_moved: originalMoved, + }, + duration_ms: Date.now() - start, + }); + } catch (err) { + console.error("❌ Errore HARD DELETE /activities/:id:", err); + + res.status(500).json({ + ok: false, + error: err.message, + }); + } +}); + +// ----------------------------------------------------- +// GET /activities/:id/route-points +// Carica tutti i punti della traccia FIT su richiesta. +// I punti non vengono salvati nel database. +// Deve stare PRIMA di GET /activities/:id. +// ----------------------------------------------------- +router.get("/:id/route-points", async (req, res) => { + const id = req.params.id; + + const loggedUser = req.user?.name; + + //const startedAt = Date.now(); + + if (!loggedUser) { + return res.status(401).json({ + ok: false, + error: "Unauthorized", + }); + } + + try { + const access = getUserAccess(loggedUser); + + if (!access) { + return res.status(403).json({ + ok: false, + error: "Utente non configurato", + user: loggedUser, + }); + } + + if (access.readableSources.length === 0) { + return res.status(404).json({ + ok: false, + error: "Activity not found or not allowed", + id, + }); + } + + const row = await db("activities") + .where({ id }) + .whereIn("user", access.readableSources) + .first(); + + if (!row) { + return res.status(404).json({ + ok: false, + error: "Activity not found or not allowed", + id, + }); + } + + const format = String(row.format || row.file_extension || "") + .toLowerCase() + .replaceAll(".", "") + .trim(); + + const fileName = String(row.file_name || "").toLowerCase(); + + const isFit = format === "fit" || fileName.endsWith(".fit"); + + if (!isFit) { + return res.status(400).json({ + ok: false, + error: "Full route endpoint currently supports FIT activities only", + id, + format, + }); + } + + const originalPath = getOriginalActivityPath(row); + + const exists = await pathExists(originalPath); + + if (!exists) { + console.warn( + "[activity route-points] File FIT non trovato:", + originalPath, + ); + + return res.status(404).json({ + ok: false, + error: "Activity file not found", + id, + }); + } + + const points = await parseFitTrackPoints(originalPath); + /* + console.log( + '[activity route-points] traccia FIT caricata', + { + id, + user: + row.user, + requestedBy: + loggedUser, + expectedPoints: + row.point_count, + loadedPoints: + points.length, + durationMs: + Date.now() - + startedAt + } + ); +*/ + res.set("Cache-Control", "private, no-store"); + + return res.json({ + ok: true, + activity_id: row.id, + format: "FIT", + expected_point_count: Number(row.point_count) || null, + point_count: points.length, + points, + }); + } catch (error) { + console.error("[activity route-points] Errore:", error); + + return res.status(500).json({ + ok: false, + error: "Errore caricamento traccia FIT", + details: error.message, + }); + } +}); + +// ----------------------------------------------------- +// GET /activities/:id +// Deve stare DOPO deleted_hard, changes, byIds, initDB, +// sync_done, toggle-soft e delete. +// ----------------------------------------------------- +router.get("/:id", async (req, res) => { + const access = getActivityReadAccess(req, res); + + if (!access) { + return; + } + + const id = req.params.id; + const start = Date.now(); + + console.log( + `📤 [GET /activities/${id}] Richiesta da=${access.name} ` + + `at=${new Date().toISOString()}`, + ); + + if (access.readableSources.length === 0) { + return res.json([]); + } + + try { + const rows = await db("activities") + .where({ id }) + .whereIn("user", access.readableSources); + + console.log( + `📤 [GET /activities/${id}] Risposta: ${rows.length} attività ` + + `durata=${Date.now() - start}ms`, + ); + + if (!rows.length) { + return res.json([]); + } + + return res.json(rows.map(normalizeActivity)); + } catch (err) { + console.error("❌ Errore GET /activities/:id:", err); + + return res.status(500).json({ + error: err.message, + }); + } +}); + +module.exports = router; diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..8ebc744 --- /dev/null +++ b/routes/auth.js @@ -0,0 +1,46 @@ +const express = require('express'); +const bcrypt = require('bcrypt'); +const fs = require('fs'); +const router = express.Router(); + +const { createToken, addToDenylist } = require('../utils/jwtHelpers'); + +// carica utenti +const userdb = JSON.parse(fs.readFileSync('./api_v1/users.json', 'utf-8')); + +// LOGIN +router.post('/login', async (req, res) => { + const { email, password } = req.body; + + const user = userdb.users.find((u) => u.email === email); + if (!user) { + return res.status(401).json({ message: 'Incorrect email or password' }); + } + + const ok = await bcrypt.compare(password, user.password); + if (!ok) { + return res.status(401).json({ message: 'Incorrect email or password' }); + } + + const token = createToken({ + id: user.id, + email: user.email, + name: user.name, + }); + + res.json({ token, name: user.name }); +}); + +// LOGOUT +router.post('/logout', (req, res) => { + const auth = req.headers.authorization || ''; + const [scheme, token] = auth.split(' '); + + if (scheme === 'Bearer' && token) { + addToDenylist(token); + } + + res.status(204).end(); +}); + +module.exports = router; diff --git a/routes/autoScan.js b/routes/autoScan.js new file mode 100644 index 0000000..9ca5304 --- /dev/null +++ b/routes/autoScan.js @@ -0,0 +1,605 @@ +// routes/autoScan.js +const express = require('express'); +const router = express.Router(); +const path = require('path'); +const fs = require('fs'); +const fsp = require('fs/promises'); + +const db = require('../db/knex'); +const { WEB_ROOT } = require('../api_v1/config'); + +const scanFile = require('../api_v1/scanner/scanFileEntry'); +const scanPhotoSingle = require('../api_v1/scanner/scanPhotoSingle'); +const scanCartella = require('../api_v1/scanner/scanCartella'); +const scanActivitySingle = require('../api_v1/scanner/scanActivitySingle'); +const scanActivityCartella = require('../api_v1/scanner/scanActivityCartella'); + +const createCleanupFunctions = require('../api_v1/scanner/orphanCleanup'); +const { deleteThumbsById } = createCleanupFunctions(db, parseInt(process.env.PHOTO_RETENTION_DAYS || "30", 10)); + +const createDeleteFolderFunctions = require('../api_v1/scanner/deleteFolder'); +const { deleteFolderForUser } = createDeleteFolderFunctions(db, parseInt(process.env.PHOTO_RETENTION_DAYS || "30", 10)); + +const wss = require('../ws-server'); + +// =============================== +// Helpers +// =============================== +function relPath2Cartella(p) { + const parts = p.split("/").filter(Boolean); + return parts.slice(3).join("/"); +} + +function getArea(relPath) { + const parts = relPath.split('/').filter(Boolean); + return parts[0]; +} + +const { sha256 } = require('../api_v1/scanner/utils'); + +function activityId(user, cartella, file) { + return sha256(`${user}/${cartella}/${file}`); +} + + +// =============================== +// AUTO SCAN ROUTER +// =============================== +router.post('/', async (req, res) => { + const { type, file, path: relPath, user } = req.body; + + const area = getArea(relPath); + const cart = relPath2Cartella(relPath); + const absFile = path.join( + __dirname, + '..', + WEB_ROOT, + relPath, + file + ); + + try { + switch (type) { + +// =============================== +// ADD_DIR — aggiunta cartella (bulk) +// =============================== +case 'ADD_DIR': { + + // =================================== + // ACTIVITIES + // =================================== + if (area === 'activities') { + + const absCartella = path.join( + __dirname, + '..', + WEB_ROOT, + 'activities', + user, + 'original', + file + ); + + const newActivities = []; + const CURRENT = { value: 0 }; + const TOTAL_FILES = 0; + const start = Date.now(); + const since = new Date().toISOString(); + +await scanActivityCartella( + db, + user, + file, + absCartella, + newActivities, + async () => {}, + CURRENT, + TOTAL_FILES, + start, + deleteThumbsById, + async () => {}, + null +); + +wss.broadcastToUser(user, { + type: "add_dir", + mode: "activities", + bulk: true, + count: newActivities.length, + since +}); + +wss.broadcastToUser(user, { + type: "add_dir_done", + mode: "activities", + folder: file, + count: newActivities.length +}); + + return res.json({ + status: 'OK', + action: 'ADD_DIR', + entity: 'activities', + count: newActivities.length + }); + } + + // =================================== + // PHOTOS + // =================================== + const absCartella = path.join( + __dirname, + '..', + WEB_ROOT, + "photos", + user, + "original", + file + ); + + const BULK_THRESHOLD = + parseInt(process.env.WS_BULK_THRESHOLD || "500", 10); + + let count = 0; + let bulkMode = false; + + const since = new Date().toISOString(); + + for await (const f of scanCartella(user, file, absCartella, db)) { + + count++; + + await db('deleted_hard') + .where({ id: f.id, user }) + .del(); + + await db('photos') + .where({ id: f.id, user }) + .del(); + + const arr = []; + + await scanPhotoSingle( + db, + user, + file, + f, + arr + ); + + if (!bulkMode && count > BULK_THRESHOLD) { + + bulkMode = true; + + wss.broadcastToUser(user, { + type: "add_dir", + folder: file, + mode: "bulk", + count, + since + }); + + continue; + } + + if (!bulkMode) { + + wss.broadcastToUser(user, { + type: "added", + id: f.id + }); + + } + } + + if (bulkMode) { + + wss.broadcastToUser(user, { + type: "add_dir_done", + folder: file, + mode: "bulk", + count, + since + }); + + } + + return res.json({ + status: 'OK', + action: 'ADD_DIR', + count, + bulkMode + }); +} + +// =============================== +// DEL — singolo file +// =============================== +case 'DEL': { + + // =================================== + // ACTIVITIES + // =================================== + /*if (area === 'activities') { + + const id = activityId( + user, + cart, + file + ); + + await db('activities') + .where({ id, user }) + .del(); + + await db('deleted_hard_activities') + .insert({ + id, + user, + deleted_at: new Date().toISOString() + }) + .onConflict('id') + .merge(); + + wss.broadcastToUser(user, { + type: "removed", + mode: "activities", + id + }); + + return res.json({ + status: 'OK', + action: 'DEL', + entity: 'activities', + id + }); + }*/ +if (area === 'activities') { + + // 1️⃣ Trovo l'attività in base a cartella + file_name + const activity = await db('activities') + .where({ + user, + cartella: cart, + file_name: file + }) + .first(); + + if (!activity) { + // Nessuna attività trovata: non c'è nulla da cancellare + return res.json({ + status: 'OK', + action: 'DEL', + entity: 'activities', + skipped: true + }); + } + + const id = activity.id; + + // 2️⃣ Cancello dal DB principale + await db('activities') + .where({ id, user }) + .del(); + + // 3️⃣ Segno la cancellazione hard + await db('deleted_hard_activities') + .insert({ + id, + user, + deleted_at: new Date().toISOString() + }) + .onConflict('id') + .merge(); + + // 4️⃣ Notifica WS + wss.broadcastToUser(user, { + type: "removed", + mode: "activities", + id + }); + + return res.json({ + status: 'OK', + action: 'DEL', + entity: 'activities', + id + }); +} + + + // =================================== + // PHOTOS + // =================================== + const e = await scanFile(user, cart, absFile); + + await deleteThumbsById(e.id); + + await db('photos') + .where({ id: e.id, user }) + .del(); + + try { + + await db('deleted_hard') + .insert({ + id: e.id, + user, + deleted_at: new Date().toISOString() + }) + .onConflict('id') + .merge(); + + } catch (err) { + + console.warn( + "⚠️ deleted_hard già presente o errore non bloccante:", + err.message + ); + + } + + wss.broadcastToUser(user, { + type: "del", + id: e.id + }); + + return res.json({ + status: 'OK', + action: 'DEL', + id: e.id + }); +} + +// =============================== +// ADD — singolo file +// =============================== +case 'ADD': { + + // =================================== + // ACTIVITIES + // =================================== + if (area === 'activities') { + + const ext = path.extname(file).toLowerCase(); + + if (!['.fit', '.gpx', '.tcx'].includes(ext)) { + + return res.status(400).json({ + error: `Estensione attività non supportata: ${ext}` + }); + + } + + let st; + + try { + + st = await fsp.stat(absFile); + + } catch { + + return res.status(404).json({ + error: 'File attività non trovato' + }); + + } + +const f = { + id: `${cart}/${file}`, + name: file, + file_name: file, + relPath: `${cart}/${file}`, // ✔ percorso corretto + absPath: absFile, + ext, + stat: st, + path: `/activities/${user}/original/${cart}/${file}` +}; + + + const newActivities = []; + + const activity = await scanActivitySingle( + db, + user, + cart, + f, + newActivities + ); + + if (!activity) { + + return res.json({ + status: 'OK', + action: 'ADD', + skipped: true + }); + + } + + wss.broadcastToUser(user, { + type: 'added', + mode: 'activities', + id: activity.id + }); + + return res.json({ + status: 'OK', + action: 'ADD', + entity: 'activities', + id: activity.id + }); + } + + // =================================== + // PHOTOS + // =================================== + const f = await scanFile( + user, + cart, + absFile + ); + + await db('deleted_hard') + .where({ id: f.id, user }) + .del(); + + await db('photos') + .where({ id: f.id, user }) + .del(); + + const newFiles = []; + + await scanPhotoSingle( + db, + user, + cart, + f, + newFiles + ); + + wss.broadcastToUser(user, { + type: "added", + id: f.id + }); + + return res.json({ + status: 'OK', + action: 'ADD', + id: f.id + }); +} + +// =============================== +// DEL_DIR — rimozione cartella (bulk) +// =============================== +case 'DEL_DIR': { + + // =================================== + // ACTIVITIES + // =================================== + if (area === 'activities') { + + const activities = await db('activities') + .where({ + user, + cartella: file + }); + + for (const a of activities) { + + await db('activities') + .where({ id: a.id, user }) + .del(); + + await db('deleted_hard_activities') + .insert({ + id: a.id, + user, + deleted_at: new Date().toISOString() + }) + .onConflict('id') + .merge(); + + wss.broadcastToUser(user, { + type: "removed", + mode: "activities", + id: a.id + }); + } + + return res.json({ + status: "OK", + action: "DEL_DIR", + entity: "activities", + count: activities.length + }); + } + + // =================================== + // PHOTOS + // =================================== + const photos = await db('photos') + .where({ + user, + cartella: file + }); + + const BULK_THRESHOLD = + parseInt(process.env.WS_BULK_THRESHOLD || "500", 10); + + const since = new Date().toISOString(); + + const bulkMode = + photos.length > BULK_THRESHOLD; + + if (bulkMode) { + + wss.broadcastToUser(user, { + type: "del_dir", + folder: file, + mode: "bulk", + count: photos.length, + since + }); + + } + + for (const p of photos) { + + await db('photos') + .where({ id: p.id, user }) + .del(); + + await deleteThumbsById(p.id); + + await db('deleted_hard') + .insert({ + id: p.id, + user, + deleted_at: new Date().toISOString() + }); + + if (!bulkMode) { + + wss.broadcastToUser(user, { + type: "del", + id: p.id + }); + + } + } + + if (bulkMode) { + + wss.broadcastToUser(user, { + type: "del_dir_done", + folder: file, + mode: "bulk", + count: photos.length, + since + }); + + } + + return res.json({ + status: 'OK', + action: 'DEL_DIR', + folder: file, + count: photos.length, + bulkMode + }); +} + + default: + return res.status(400).json({ error: 'Tipo non valido' }); + } + + } catch (err) { + console.error('Errore auto_scan:', err); + res.status(500).json({ error: 'Errore durante auto_scan', details: err.message }); + } +}); + +module.exports = router; diff --git a/routes/config.js b/routes/config.js new file mode 100644 index 0000000..e3dcf7b --- /dev/null +++ b/routes/config.js @@ -0,0 +1,23 @@ +// routes/config.js + +const express = require("express"); +const config = require("../api_v1/config"); + +const router = express.Router(); + +router.get("/", (req, res) => { + res.setHeader("Cache-Control", "no-store"); + + res.json({ + baseUrl: config.BASE_URL, + pathFull: config.PATH_FULL, + galleryRefreshSeconds: + config.GALLERY_REFRESH_SECONDS, + workerStatusRefreshSeconds: + config.WORKER_STATUS_REFRESH_SECONDS, + mapboxToken: + process.env.MAPBOX_PUBLIC_TOKEN || null + }); +}); + +module.exports = router; diff --git a/routes/faceReview.js b/routes/faceReview.js new file mode 100644 index 0000000..51f2fa4 --- /dev/null +++ b/routes/faceReview.js @@ -0,0 +1,2917 @@ +const express = require('express'); +const { randomUUID } = require('crypto'); +const sharp = require('sharp'); + +const db = require('../db/knex'); + +const { + resolveFilePathForEntryId, +} = require('../utils/resolveFilePath'); + +const { + createHttpError, + getAuthenticatedUser, + isFaceReviewAdmin, + resolveFaceReviewUser, +} = require('../middleware/faceReviewUser'); + + +const { + runCycle: runFaceClusteringCycle, + clusterUserIfNeeded, +} = require( + '../tools/runFaceClusteringScheduler' +); + +const router = express.Router(); + +const DEFAULT_THUMBNAIL_SIZE = 192; +const MIN_THUMBNAIL_SIZE = 64; +const MAX_THUMBNAIL_SIZE = 512; +const FACE_MARGIN_FACTOR = 0.25; + + +function parseThumbnailSize(value) { + if (value == null || value === '') { + return DEFAULT_THUMBNAIL_SIZE; + } + + const size = Number(value); + + if ( + !Number.isInteger(size) + || size < MIN_THUMBNAIL_SIZE + || size > MAX_THUMBNAIL_SIZE + ) { + throw createHttpError( + 400, + `size deve essere compreso tra ` + + `${MIN_THUMBNAIL_SIZE} e ` + + `${MAX_THUMBNAIL_SIZE}`, + ); + } + + return size; +} + + +function normalizeRotation(value) { + let rotation = Number(value || 0); + + if (!Number.isInteger(rotation)) { + throw createHttpError( + 500, + `Rotazione foto non valida: ${value}`, + ); + } + + rotation %= 360; + + if (rotation < 0) { + rotation += 360; + } + + if ( + rotation !== 0 + && rotation !== 90 + && rotation !== 180 + && rotation !== 270 + ) { + throw createHttpError( + 500, + `Rotazione foto non supportata: ${value}`, + ); + } + + return rotation; +} + + +function calculateSquareCrop( + face, + imageWidth, + imageHeight, +) { + const faceX = Number(face.x); + const faceY = Number(face.y); + const faceWidth = Number(face.width); + const faceHeight = Number(face.height); + + if ( + !Number.isFinite(faceX) + || !Number.isFinite(faceY) + || !Number.isFinite(faceWidth) + || !Number.isFinite(faceHeight) + || faceWidth <= 0 + || faceHeight <= 0 + ) { + throw createHttpError( + 500, + 'Bounding box del volto non valida', + ); + } + + const centreX = + faceX + faceWidth / 2; + + const centreY = + faceY + faceHeight / 2; + + const largestSide = Math.max( + faceWidth, + faceHeight, + ); + + let cropSize = Math.ceil( + largestSide + * (1 + FACE_MARGIN_FACTOR * 2), + ); + + cropSize = Math.max( + 1, + Math.min( + cropSize, + imageWidth, + imageHeight, + ), + ); + + let left = Math.round( + centreX - cropSize / 2, + ); + + let top = Math.round( + centreY - cropSize / 2, + ); + + left = Math.max( + 0, + Math.min( + left, + imageWidth - cropSize, + ), + ); + + top = Math.max( + 0, + Math.min( + top, + imageHeight - cropSize, + ), + ); + + return { + left, + top, + width: cropSize, + height: cropSize, + }; +} + + +async function ensureTargetUserExists(user) { + const run = await db('face_cluster_runs') + .where({ user }) + .first('id'); + + if (run) { + return; + } + + const photo = await db('photos') + .where({ user }) + .first('id'); + + if (!photo) { + throw createHttpError( + 404, + `Utente non trovato: ${user}`, + ); + } +} + + +async function getLatestCompletedRun(user) { + return db('face_cluster_runs') + .where({ + user, + status: 'done', + }) + .orderBy('completed_at', 'desc') + .orderBy('created_at', 'desc') + .first(); +} + + +function buildFaceResponse( + face, + targetUser, +) { + const faceId = encodeURIComponent( + face.face_id, + ); + + return { + id: face.face_id, + photo_id: face.photo_id, + photo_name: face.photo_name, + photo_path: face.photo_path, + face_index: Number(face.face_index), + detector_score: + Number(face.detector_score), + probability: + face.probability == null + ? null + : Number(face.probability), + review_status: + face.review_status ?? null, + person_id: + face.person_id ?? null, + rotation: + Number(face.rotation || 0), + thumbnail_url: + `/face-review/faces/${faceId}` + + '/thumbnail', + }; +} + + +async function loadClusterFaces( + runId, + clusterId, + targetUser, +) { + const rows = await db( + 'face_cluster_memberships as m', + ) + .join( + 'faces as f', + 'f.id', + 'm.face_id', + ) + .join( + 'photos as p', + 'p.id', + 'f.photo_id', + ) + .join( + 'face_cluster_runs as r', + 'r.id', + 'm.run_id', + ) + .where({ + 'm.run_id': runId, + 'm.cluster_id': clusterId, + 'r.user': targetUser, + 'p.user': targetUser, + }) + .select( + 'f.id as face_id', + 'f.photo_id', + 'f.face_index', + 'f.score as detector_score', + 'f.person_id', + 'p.name as photo_name', + 'p.path as photo_path', + 'p.rotation', + 'm.probability', + 'm.review_status', + ) + .orderBy('m.probability', 'desc') + .orderBy('p.name', 'asc') + .orderBy('f.face_index', 'asc'); + + return rows.map( + (row) => buildFaceResponse( + row, + targetUser, + ), + ); +} + + + +function normalizePersonName(value) { + const name = String( + value || '', + ) + .replace(/\s+/g, ' ') + .trim(); + + if (!name) { + throw createHttpError( + 400, + 'Nome della persona mancante', + ); + } + + if (name.length > 120) { + throw createHttpError( + 400, + 'Nome della persona troppo lungo', + ); + } + + return name; +} + + +function normalizeFaceIds(value) { + if (!Array.isArray(value)) { + throw createHttpError( + 400, + 'included_face_ids deve essere un array', + ); + } + + const result = []; + const seen = new Set(); + + for (const rawValue of value) { + const faceId = String( + rawValue || '', + ).trim(); + + if (!faceId || seen.has(faceId)) { + continue; + } + + seen.add(faceId); + result.push(faceId); + } + + if (!result.length) { + throw createHttpError( + 400, + 'Seleziona almeno un volto', + ); + } + + return result; +} + + +async function findPersonByName( + connection, + user, + name, +) { + return connection('persons') + .where({ user }) + .whereRaw( + 'LOWER(name) = LOWER(?)', + [name], + ) + .first(); +} + + +async function loadOwnedCluster( + connection, + clusterId, + user, +) { + return connection( + 'face_clusters as c', + ) + .join( + 'face_cluster_runs as r', + 'r.id', + 'c.run_id', + ) + .where({ + 'c.id': clusterId, + 'r.user': user, + }) + .first( + 'c.id', + 'c.run_id', + 'c.label', + 'c.size', + 'c.person_id', + ); +} + + +async function loadClusterMemberships( + connection, + clusterId, + user, +) { + return connection( + 'face_cluster_memberships as m', + ) + .join( + 'faces as f', + 'f.id', + 'm.face_id', + ) + .join( + 'photos as p', + 'p.id', + 'f.photo_id', + ) + .join( + 'face_cluster_runs as r', + 'r.id', + 'm.run_id', + ) + .where({ + 'm.cluster_id': clusterId, + 'm.noise_flag': 0, + 'p.user': user, + 'r.user': user, + }) + .select( + 'm.id as membership_id', + 'm.face_id', + 'm.review_status', + 'f.person_id', + ); +} + + + +async function queuePrototypeRefresh( + connection, + { + user, + personId, + reason, + requestedBy, + }, +) { + const normalizedReason = String( + reason || 'manual_rebuild', + ).trim(); + + const normalizedRequestedBy = String( + requestedBy || user || '', + ).trim(); + + const now = + new Date().toISOString(); + + /* + * Se esiste già un job pending per la + * persona, aggiorna quello invece di + * crearne uno nuovo. + */ + const existing = await connection( + 'face_prototype_jobs', + ) + .where({ + user, + person_id: personId, + status: 'pending', + }) + .orderBy( + 'requested_at', + 'desc', + ) + .first(); + + if (existing) { + await connection( + 'face_prototype_jobs', + ) + .where({ + id: existing.id, + }) + .update({ + reason: normalizedReason, + requested_by: + normalizedRequestedBy || null, + requested_at: now, + last_error: null, + updated_at: now, + }); + + return { + created: false, + job: { + ...existing, + reason: normalizedReason, + requested_by: + normalizedRequestedBy || null, + requested_at: now, + last_error: null, + updated_at: now, + }, + }; + } + + const job = { + id: randomUUID(), + user, + person_id: personId, + status: 'pending', + reason: normalizedReason, + requested_by: + normalizedRequestedBy || null, + requested_at: now, + started_at: null, + completed_at: null, + attempts: 0, + last_error: null, + result_json: null, + input_signature: null, + created_at: now, + updated_at: now, + }; + + await connection( + 'face_prototype_jobs', + ).insert(job); + + return { + created: true, + job, + }; +} + + + +router.get('/context', async (req, res) => { + try { + + runFaceClusteringCycle() + .catch((error) => { + console.error( + '[faceReview] clusterScheduler', + error, + ); + }); + + const authenticatedUser = + getAuthenticatedUser(req); + + const targetUser = + resolveFaceReviewUser(req); + + await clusterUserIfNeeded( + targetUser, + ); + + await ensureTargetUserExists( + targetUser, + ); + + const latestRun = + await getLatestCompletedRun( + targetUser, + ); + + res.json({ + authenticated_user: + authenticatedUser, + target_user: + targetUser, + is_admin: + isFaceReviewAdmin(req), + can_select_user: + isFaceReviewAdmin(req), + latest_run_id: + latestRun?.id ?? null, + }); + } catch (error) { + res.status(error.status || 500).json({ + ok: false, + error: error.message, + }); + } +}); + + +router.get('/runs', async (req, res) => { + try { + const targetUser = + resolveFaceReviewUser(req); + + await ensureTargetUserExists( + targetUser, + ); + + const rows = await db( + 'face_cluster_runs', + ) + .where({ user: targetUser }) + .orderBy('created_at', 'desc') + .select( + 'id', + 'user', + 'status', + 'scope', + 'embedding_count', + 'min_cluster_size', + 'min_samples', + 'metric', + 'selection_method', + 'engine', + 'engine_version', + 'cluster_count', + 'clustered_faces', + 'noise_faces', + 'noise_percent', + 'started_at', + 'completed_at', + 'created_at', + ); + + res.json({ + user: targetUser, + runs: rows, + }); + } catch (error) { + res.status(error.status || 500).json({ + ok: false, + error: error.message, + }); + } +}); + + +router.get('/clusters', async (req, res) => { + try { + const targetUser = + resolveFaceReviewUser(req); + + await ensureTargetUserExists( + targetUser, + ); + + const requestedRunId = String( + req.query.run_id || '', + ).trim(); + + const run = requestedRunId + ? await db('face_cluster_runs') + .where({ + id: requestedRunId, + user: targetUser, + }) + .first() + : await getLatestCompletedRun( + targetUser, + ); + + if (!run) { + return res.json({ + user: targetUser, + run: null, + clusters: [], + }); + } + + const clusterRows = await db( + 'face_clusters as c', + ) + .leftJoin( + 'persons as person', + 'person.id', + 'c.person_id', + ) + .where({ + 'c.run_id': run.id, + }) + .select( + 'c.id', + 'c.run_id', + 'c.label', + 'c.size', + 'c.min_samples', + 'c.min_cluster_size', + 'c.person_id', + 'person.name as person_name', + 'c.created_at', + 'c.updated_at', + ) + .orderBy('c.label', 'asc'); + + const clusters = []; + + for (const cluster of clusterRows) { + const faces = await loadClusterFaces( + run.id, + cluster.id, + targetUser, + ); + + clusters.push({ + ...cluster, + label: Number(cluster.label), + size: Number(cluster.size), + faces, + }); + } + + res.json({ + user: targetUser, + run, + clusters, + }); + } catch (error) { + console.error( + '[faceReview] GET /clusters:', + error, + ); + + res.status(error.status || 500).json({ + ok: false, + error: error.message, + }); + } +}); + + +router.get( + '/clusters/:clusterId', + async (req, res) => { + try { + const targetUser = + resolveFaceReviewUser(req); + + const clusterId = String( + req.params.clusterId || '', + ).trim(); + + const cluster = await db( + 'face_clusters as c', + ) + .join( + 'face_cluster_runs as r', + 'r.id', + 'c.run_id', + ) + .leftJoin( + 'persons as person', + 'person.id', + 'c.person_id', + ) + .where({ + 'c.id': clusterId, + 'r.user': targetUser, + }) + .first( + 'c.id', + 'c.run_id', + 'c.label', + 'c.size', + 'c.min_samples', + 'c.min_cluster_size', + 'c.person_id', + 'person.name as person_name', + 'c.created_at', + 'c.updated_at', + ); + + if (!cluster) { + throw createHttpError( + 404, + 'Cluster non trovato', + ); + } + + const faces = await loadClusterFaces( + cluster.run_id, + cluster.id, + targetUser, + ); + + res.json({ + user: targetUser, + cluster: { + ...cluster, + label: Number(cluster.label), + size: Number(cluster.size), + faces, + }, + }); + } catch (error) { + res.status(error.status || 500).json({ + ok: false, + error: error.message, + }); + } + }, +); + + +router.get( + '/unclustered', + async (req, res) => { + try { + const targetUser = + resolveFaceReviewUser(req); + + const run = + await getLatestCompletedRun( + targetUser, + ); + + if (!run) { + return res.json({ + user: targetUser, + run: null, + faces: [], + }); + } + + const rows = await db( + 'face_cluster_memberships as m', + ) + .join( + 'faces as f', + 'f.id', + 'm.face_id', + ) + .join( + 'photos as p', + 'p.id', + 'f.photo_id', + ) + .join( + 'face_cluster_runs as r', + 'r.id', + 'm.run_id', + ) + .where({ + 'm.run_id': run.id, + 'm.noise_flag': 1, + 'r.user': targetUser, + 'p.user': targetUser, + }) + .whereNull('f.person_id') + .select( + 'f.id as face_id', + 'f.photo_id', + 'f.face_index', + 'f.score as detector_score', + 'f.person_id', + 'p.name as photo_name', + 'p.path as photo_path', + 'p.rotation', + 'm.probability', + 'm.review_status', + ) + .orderBy('p.name', 'asc') + .orderBy('f.face_index', 'asc'); + + res.json({ + user: targetUser, + run, + faces: rows.map( + (row) => buildFaceResponse( + row, + targetUser, + ), + ), + }); + } catch (error) { + res.status(error.status || 500).json({ + ok: false, + error: error.message, + }); + } + }, +); + + +router.get('/persons', async (req, res) => { + try { + const targetUser = + resolveFaceReviewUser(req); + + const persons = await db('persons') + .where({ user: targetUser }) + .orderBy('name', 'asc') + .select( + 'id', + 'user', + 'name', + 'created_at', + 'updated_at', + ); + + res.json({ + user: targetUser, + persons, + }); + } catch (error) { + res.status(error.status || 500).json({ + ok: false, + error: error.message, + }); + } +}); + + +router.get( + '/faces/:faceId/thumbnail', + async (req, res) => { + try { + const targetUser = + resolveFaceReviewUser(req); + + const faceId = String( + req.params.faceId || '', + ).trim(); + + const requestedSize = + parseThumbnailSize( + req.query.size, + ); + + const face = await db('faces as f') + .join( + 'photos as p', + 'p.id', + 'f.photo_id', + ) + .where({ + 'f.id': faceId, + 'p.user': targetUser, + }) + .whereNull('p.deleted_at') + .first( + 'f.id', + 'f.photo_id', + 'f.x', + 'f.y', + 'f.width', + 'f.height', + 'p.rotation', + 'p.name', + ); + + if (!face) { + throw createHttpError( + 404, + 'Volto non trovato', + ); + } + + const filePath = + await resolveFilePathForEntryId( + face.photo_id, + ); + + if (!filePath) { + throw createHttpError( + 404, + 'File originale non trovato', + ); + } + + const rotation = + normalizeRotation( + face.rotation, + ); + + const sourceMetadata = + await sharp(filePath) + .metadata(); + + const sourceWidth = + Number(sourceMetadata.width); + + const sourceHeight = + Number(sourceMetadata.height); + + if ( + !Number.isInteger(sourceWidth) + || sourceWidth <= 0 + || !Number.isInteger(sourceHeight) + || sourceHeight <= 0 + ) { + throw createHttpError( + 500, + 'Dimensioni immagine non valide', + ); + } + + const rotatedWidth = + rotation === 90 + || rotation === 270 + ? sourceHeight + : sourceWidth; + + const rotatedHeight = + rotation === 90 + || rotation === 270 + ? sourceWidth + : sourceHeight; + + const crop = + calculateSquareCrop( + face, + rotatedWidth, + rotatedHeight, + ); + + const buffer = await sharp( + filePath, + { + failOn: 'none', + }, + ) + .rotate(rotation) + .extract(crop) + .resize({ + width: requestedSize, + height: requestedSize, + fit: 'cover', + position: 'centre', + withoutEnlargement: false, + }) + .webp({ + quality: 82, + effort: 4, + }) + .toBuffer(); + + res.set({ + 'Content-Type': 'image/webp', + 'Content-Length': + String(buffer.length), + 'Cache-Control': + 'private, max-age=3600', + 'X-Content-Type-Options': + 'nosniff', + }); + + res.send(buffer); + } catch (error) { + console.error( + '[faceReview] thumbnail error:', + error, + ); + + res.status(error.status || 500).json({ + ok: false, + error: error.message, + }); + } + }, +); + + + +router.post('/persons', async (req, res) => { + try { + const targetUser = + resolveFaceReviewUser(req); + + const name = + normalizePersonName( + req.body?.name, + ); + + const result = await db.transaction( + async (trx) => { + const existing = + await findPersonByName( + trx, + targetUser, + name, + ); + + if (existing) { + return { + person: existing, + created: false, + }; + } + + const now = + new Date().toISOString(); + + const person = { + id: randomUUID(), + user: targetUser, + name, + created_at: now, + updated_at: now, + }; + + await trx('persons') + .insert(person); + + return { + person, + created: true, + }; + }, + ); + + res.status( + result.created ? 201 : 200, + ).json({ + ok: true, + user: targetUser, + created: result.created, + person: result.person, + }); + } catch (error) { + console.error( + '[faceReview] POST /persons:', + error, + ); + + res.status(error.status || 500).json({ + ok: false, + error: error.message, + }); + } +}); + + + + +router.post( + '/clusters/:clusterId/assign-person', + async (req, res) => { + try { + const authenticatedUser = + getAuthenticatedUser(req); + + const targetUser = + resolveFaceReviewUser(req); + + const clusterId = String( + req.params.clusterId || '', + ).trim(); + + const personId = String( + req.body?.person_id || '', + ).trim(); + + if (!clusterId) { + throw createHttpError( + 400, + 'Cluster mancante', + ); + } + + if (!personId) { + throw createHttpError( + 400, + 'Persona mancante', + ); + } + + const includedFaceIds = + normalizeFaceIds( + req.body?.included_face_ids, + ); + + const result = await db.transaction( + async (trx) => { + const person = + await trx('persons') + .where({ + id: personId, + user: targetUser, + }) + .first(); + + if (!person) { + throw createHttpError( + 404, + 'Persona non trovata', + ); + } + + const cluster = + await loadOwnedCluster( + trx, + clusterId, + targetUser, + ); + + if (!cluster) { + throw createHttpError( + 404, + 'Cluster non trovato', + ); + } + + const memberships = + await loadClusterMemberships( + trx, + clusterId, + targetUser, + ); + + if (!memberships.length) { + throw createHttpError( + 409, + 'Il cluster non contiene volti', + ); + } + + const membershipFaceIds = + new Set( + memberships.map( + (membership) => + membership.face_id, + ), + ); + + for ( + const faceId + of includedFaceIds + ) { + if ( + !membershipFaceIds.has(faceId) + ) { + throw createHttpError( + 400, + 'Il volto non appartiene ' + + `al cluster: ${faceId}`, + ); + } + } + + const includedSet = + new Set(includedFaceIds); + + const now = + new Date().toISOString(); + + const excludedFaceIds = []; + + for ( + const membership + of memberships + ) { + const included = + includedSet.has( + membership.face_id, + ); + + await trx( + 'face_cluster_memberships', + ) + .where({ + id: + membership.membership_id, + }) + .update({ + review_status: + included + ? 'accepted' + : 'excluded', + reviewed_by: + authenticatedUser, + reviewed_at: now, + updated_at: now, + }); + + if (included) { + await trx('faces') + .where({ + id: membership.face_id, + }) + .update({ + person_id: personId, + updated_at: now, + }); + + await trx( + 'person_face_exclusions', + ) + .where({ + person_id: personId, + face_id: + membership.face_id, + }) + .del(); + + continue; + } + + excludedFaceIds.push( + membership.face_id, + ); + + await trx('faces') + .where({ + id: membership.face_id, + }) + .where((builder) => { + builder + .whereNull('person_id') + .orWhere( + 'person_id', + personId, + ); + }) + .update({ + person_id: null, + updated_at: now, + }); + + await trx( + 'person_face_exclusions', + ) + .insert({ + person_id: personId, + face_id: + membership.face_id, + user: targetUser, + reason: + 'Escluso manualmente ' + + 'durante la revisione ' + + 'del cluster', + created_by: + authenticatedUser, + created_at: now, + }) + .onConflict([ + 'person_id', + 'face_id', + ]) + .merge({ + user: targetUser, + reason: + 'Escluso manualmente ' + + 'durante la revisione ' + + 'del cluster', + created_by: + authenticatedUser, + created_at: now, + }); + } + + await trx('face_clusters') + .where({ + id: clusterId, + }) + .update({ + person_id: personId, + updated_at: now, + }); + + await queuePrototypeRefresh( + + trx, + + { + + user: targetUser, + + personId, + + reason: + + 'cluster_assigned', + + requestedBy: + + authenticatedUser, + + }, + + ); + + + return { + cluster_id: clusterId, + cluster_label: + Number(cluster.label), + person: { + id: person.id, + user: person.user, + name: person.name, + }, + included_face_ids: + includedFaceIds, + excluded_face_ids: + excludedFaceIds, + }; + }, + ); + + res.json({ + ok: true, + user: targetUser, + ...result, + }); + } catch (error) { + console.error( + '[faceReview] assign person:', + error, + ); + + res.status(error.status || 500).json({ + ok: false, + error: error.message, + }); + } + }, +); + + + + +/* + * Dettaglio persona con tutti i volti assegnati, + * prototype attivi e casi difficili. + */ +router.get( + '/persons/:personId', + async (req, res) => { + try { + const targetUser = + resolveFaceReviewUser(req); + + const personId = String( + req.params.personId || '', + ).trim(); + + const person = await db('persons') + .where({ + id: personId, + user: targetUser, + }) + .first(); + + if (!person) { + throw createHttpError( + 404, + 'Persona non trovata', + ); + } + + const rows = await db('faces as f') + .join( + 'photos as p', + 'p.id', + 'f.photo_id', + ) + .leftJoin( + 'face_clusters as c', + 'c.id', + 'f.cluster_id', + ) + .leftJoin( + 'person_face_prototypes as prototype', + function joinPrototype() { + this.on( + 'prototype.face_id', + '=', + 'f.id', + ); + + this.andOn( + 'prototype.person_id', + '=', + 'f.person_id', + ); + }, + ) + .leftJoin( + 'person_face_reference_exclusions as exclusion', + function joinReferenceExclusion() { + this.on( + 'exclusion.face_id', + '=', + 'f.id', + ); + + this.andOn( + 'exclusion.person_id', + '=', + 'f.person_id', + ); + }, + ) + .where({ + 'f.person_id': personId, + 'p.user': targetUser, + }) + .select( + 'f.id as face_id', + 'f.photo_id', + 'f.face_index', + 'f.score as detector_score', + 'f.status', + 'p.name as photo_name', + 'p.path as photo_path', + 'p.rotation', + 'c.id as cluster_id', + 'c.label as cluster_label', + + 'prototype.active as prototype_active', + 'prototype.source_type as prototype_source_type', + 'prototype.selection_method as prototype_selection_method', + 'prototype.selection_order as prototype_selection_order', + 'prototype.quality_score as prototype_quality_score', + 'prototype.experiment_name as prototype_experiment_name', + + 'exclusion.reason_code as reference_exclusion_reason', + 'exclusion.note as reference_exclusion_note', + 'exclusion.created_by as reference_exclusion_created_by', + 'exclusion.created_at as reference_exclusion_created_at', + ) + .orderByRaw( + ` + CASE + WHEN prototype.active = 1 + THEN 0 + ELSE 1 + END + `, + ) + .orderBy( + 'prototype.selection_order', + 'asc', + ) + .orderBy( + 'c.label', + 'asc', + ) + .orderBy( + 'p.name', + 'asc', + ) + .orderBy( + 'f.face_index', + 'asc', + ); + + const clusterCount = await db( + 'face_clusters', + ) + .where({ + person_id: personId, + }) + .countDistinct({ + count: 'id', + }) + .first(); + + const indexState = await db( + 'face_recognition_index_state', + ) + .where({ + user: targetUser, + }) + .first( + 'user', + 'status', + 'dirty', + 'version', + 'prototype_count', + 'index_path', + 'metadata_path', + 'built_at', + 'last_error', + 'updated_at', + ); + + const recognitionRows = + await db( + 'person_face_recognition_evaluations as evaluation', + ) + .leftJoin( + 'faces as best_prototype_face', + 'best_prototype_face.id', + 'evaluation.best_prototype_face_id', + ) + .leftJoin( + 'photos as best_prototype_photo', + 'best_prototype_photo.id', + 'best_prototype_face.photo_id', + ) + .where({ + 'evaluation.person_id': + personId, + 'evaluation.user': + targetUser, + }) + .select( + 'evaluation.face_id', + 'evaluation.prototype_job_id', + 'evaluation.prototype_count', + 'evaluation.best_similarity', + 'evaluation.top3_mean', + 'evaluation.best_prototype_face_id', + 'evaluation.recognition_threshold', + 'evaluation.recognized', + 'evaluation.evaluated_at', + 'best_prototype_photo.name as best_prototype_photo_name', + ); + + const recognitionByFaceId = + new Map( + recognitionRows.map( + (evaluation) => [ + evaluation.face_id, + evaluation, + ], + ), + ); + + const faces = rows.map((row) => { + const faceId = encodeURIComponent( + row.face_id, + ); + + const isPrototype = + Number(row.prototype_active || 0) + === 1; + + const hasReferenceExclusion = + Boolean( + row.reference_exclusion_reason, + ); + + const recognitionEvaluation = + recognitionByFaceId.get( + row.face_id, + ) || null; + + return { + id: row.face_id, + photo_id: row.photo_id, + photo_name: row.photo_name, + photo_path: row.photo_path, + + face_index: + Number(row.face_index), + + detector_score: + Number(row.detector_score), + + status: row.status, + + rotation: + Number(row.rotation || 0), + + cluster_id: + row.cluster_id || null, + + cluster_label: + row.cluster_label == null + ? null + : Number( + row.cluster_label, + ), + + source_type: + row.cluster_id == null + ? 'noise' + : 'cluster', + + is_prototype: + isPrototype, + + prototype: + isPrototype + ? { + active: true, + + source_type: + row.prototype_source_type, + + selection_method: + row.prototype_selection_method, + + selection_order: + row.prototype_selection_order + == null + ? null + : Number( + row.prototype_selection_order, + ), + + quality_score: + row.prototype_quality_score + == null + ? null + : Number( + row.prototype_quality_score, + ), + + experiment_name: + row.prototype_experiment_name + || null, + } + : null, + + is_reference_excluded: + hasReferenceExclusion, + + reference_exclusion: + hasReferenceExclusion + ? { + reason_code: + row.reference_exclusion_reason, + + note: + row.reference_exclusion_note + || null, + + created_by: + row.reference_exclusion_created_by + || null, + + created_at: + row.reference_exclusion_created_at + || null, + } + : null, + + recognition: + recognitionEvaluation + ? { + evaluated: true, + + recognized: + Number( + recognitionEvaluation + .recognized || 0, + ) === 1, + + best_similarity: + recognitionEvaluation + .best_similarity == null + ? null + : Number( + recognitionEvaluation + .best_similarity, + ), + + top3_mean: + recognitionEvaluation + .top3_mean == null + ? null + : Number( + recognitionEvaluation + .top3_mean, + ), + + threshold: + recognitionEvaluation + .recognition_threshold == null + ? null + : Number( + recognitionEvaluation + .recognition_threshold, + ), + + prototype_count: + Number( + recognitionEvaluation + .prototype_count || 0, + ), + + prototype_job_id: + recognitionEvaluation + .prototype_job_id, + + best_prototype_face_id: + recognitionEvaluation + .best_prototype_face_id + || null, + + best_prototype_photo_name: + recognitionEvaluation + .best_prototype_photo_name + || null, + + evaluated_at: + recognitionEvaluation + .evaluated_at + || null, + } + : { + evaluated: false, + recognized: false, + best_similarity: null, + top3_mean: null, + threshold: null, + prototype_count: null, + prototype_job_id: null, + best_prototype_face_id: null, + best_prototype_photo_name: null, + evaluated_at: null, + }, + + thumbnail_url: + `/face-review/faces/${faceId}` + + '/thumbnail', + }; + }); + + const prototypeCount = + faces.filter( + (face) => + face.is_prototype, + ).length; + + const difficultFaceCount = + faces.filter( + (face) => + face.is_reference_excluded, + ).length; + + const clusteredFaceCount = + faces.filter( + (face) => + face.cluster_id != null, + ).length; + + const confirmedNoiseCount = + faces.length + - clusteredFaceCount; + + const evaluatedFaces = + faces.filter( + (face) => + face.recognition.evaluated, + ); + + const recognizedFaceCount = + evaluatedFaces.filter( + (face) => + face.recognition.recognized, + ).length; + + const belowThresholdCount = + evaluatedFaces.length + - recognizedFaceCount; + + const recognitionThreshold = + evaluatedFaces.find( + (face) => + face.recognition.threshold + != null, + )?.recognition?.threshold + ?? 0.50; + + const recognitionCoveragePercent = + evaluatedFaces.length > 0 + ? Number( + ( + recognizedFaceCount + / evaluatedFaces.length + * 100 + ).toFixed(1), + ) + : null; + + res.json({ + user: targetUser, + + person, + + summary: { + face_count: + faces.length, + + cluster_count: + Number( + clusterCount?.count || 0, + ), + + prototype_count: + prototypeCount, + + difficult_face_count: + difficultFaceCount, + + clustered_face_count: + clusteredFaceCount, + + confirmed_noise_count: + confirmedNoiseCount, + + evaluated_face_count: + evaluatedFaces.length, + + recognized_face_count: + recognizedFaceCount, + + below_threshold_count: + belowThresholdCount, + + recognition_threshold: + recognitionThreshold, + + recognition_coverage_percent: + recognitionCoveragePercent, + }, + + index_state: + indexState + ? { + status: + indexState.status, + + dirty: + Number( + indexState.dirty || 0, + ) === 1, + + version: + Number( + indexState.version || 0, + ), + + prototype_count: + Number( + indexState.prototype_count + || 0, + ), + + index_path: + indexState.index_path + || null, + + metadata_path: + indexState.metadata_path + || null, + + built_at: + indexState.built_at + || null, + + last_error: + indexState.last_error + || null, + + updated_at: + indexState.updated_at + || null, + } + : { + status: 'missing', + dirty: true, + version: 0, + prototype_count: + prototypeCount, + index_path: null, + metadata_path: null, + built_at: null, + last_error: null, + updated_at: null, + }, + + faces, + }); + } catch (error) { + console.error( + '[faceReview] person detail:', + error, + ); + + res.status(error.status || 500).json({ + ok: false, + error: error.message, + }); + } + }, +); + + +/* + * Rinomina una persona. + * + * Body: + * { + * "name": "Nuovo nome" + * } + */ +router.patch( + '/persons/:personId', + async (req, res) => { + try { + const targetUser = + resolveFaceReviewUser(req); + + const personId = String( + req.params.personId || '', + ).trim(); + + const name = normalizePersonName( + req.body?.name, + ); + + const person = await db.transaction( + async (trx) => { + const existing = await trx( + 'persons', + ) + .where({ + id: personId, + user: targetUser, + }) + .first(); + + if (!existing) { + throw createHttpError( + 404, + 'Persona non trovata', + ); + } + + const duplicate = + await findPersonByName( + trx, + targetUser, + name, + ); + + if ( + duplicate + && duplicate.id !== personId + ) { + throw createHttpError( + 409, + 'Esiste già una persona ' + + 'con questo nome', + ); + } + + const now = + new Date().toISOString(); + + await trx('persons') + .where({ + id: personId, + user: targetUser, + }) + .update({ + name, + updated_at: now, + }); + + return { + ...existing, + name, + updated_at: now, + }; + }, + ); + + res.json({ + ok: true, + user: targetUser, + person, + }); + } catch (error) { + console.error( + '[faceReview] rename person:', + error, + ); + + res.status(error.status || 500).json({ + ok: false, + error: error.message, + }); + } + }, +); + + +/* + * Rimuove un volto da una persona senza + * cancellare il volto o il cluster automatico. + */ +router.delete( + '/persons/:personId/faces/:faceId', + async (req, res) => { + try { + const authenticatedUser = + getAuthenticatedUser(req); + + const targetUser = + resolveFaceReviewUser(req); + + const personId = String( + req.params.personId || '', + ).trim(); + + const faceId = String( + req.params.faceId || '', + ).trim(); + + const result = await db.transaction( + async (trx) => { + const person = await trx( + 'persons', + ) + .where({ + id: personId, + user: targetUser, + }) + .first(); + + if (!person) { + throw createHttpError( + 404, + 'Persona non trovata', + ); + } + + const face = await trx( + 'faces as f', + ) + .join( + 'photos as p', + 'p.id', + 'f.photo_id', + ) + .where({ + 'f.id': faceId, + 'f.person_id': personId, + 'p.user': targetUser, + }) + .first( + 'f.id', + 'f.cluster_id', + ); + + if (!face) { + throw createHttpError( + 404, + 'Volto non assegnato ' + + 'alla persona', + ); + } + + const now = + new Date().toISOString(); + + await trx('faces') + .where({ + id: faceId, + person_id: personId, + }) + .update({ + person_id: null, + updated_at: now, + }); + + await trx( + 'face_cluster_memberships', + ) + .where({ + face_id: faceId, + }) + .whereNotNull('cluster_id') + .update({ + review_status: 'excluded', + reviewed_by: + authenticatedUser, + reviewed_at: now, + updated_at: now, + }); + + await trx( + 'person_face_exclusions', + ) + .insert({ + person_id: personId, + face_id: faceId, + user: targetUser, + reason: + 'Rimosso manualmente ' + + 'dalla persona', + created_by: + authenticatedUser, + created_at: now, + }) + .onConflict([ + 'person_id', + 'face_id', + ]) + .merge({ + user: targetUser, + reason: + 'Rimosso manualmente ' + + 'dalla persona', + created_by: + authenticatedUser, + created_at: now, + }); + + await queuePrototypeRefresh( + + trx, + + { + + user: targetUser, + + personId, + + reason: + + 'face_removed', + + requestedBy: + + authenticatedUser, + + }, + + ); + + + return { + person_id: personId, + face_id: faceId, + cluster_id: + face.cluster_id || null, + }; + }, + ); + + res.json({ + ok: true, + user: targetUser, + ...result, + }); + } catch (error) { + console.error( + '[faceReview] remove face:', + error, + ); + + res.status(error.status || 500).json({ + ok: false, + error: error.message, + }); + } + }, +); + + + + +/* + * Assegna manualmente un singolo volto + * a una persona. + * + * Il volto può essere noise HDBSCAN: + * cluster_id resta invariato, anche NULL. + * + * Body: + * { + * "person_id": "..." + * } + */ +router.post( + '/faces/:faceId/assign-person', + async (req, res) => { + try { + const authenticatedUser = + getAuthenticatedUser(req); + + const targetUser = + resolveFaceReviewUser(req); + + const faceId = String( + req.params.faceId || '', + ).trim(); + + const personId = String( + req.body?.person_id || '', + ).trim(); + + if (!faceId) { + throw createHttpError( + 400, + 'Volto mancante', + ); + } + + if (!personId) { + throw createHttpError( + 400, + 'Persona mancante', + ); + } + + const result = await db.transaction( + async (trx) => { + const person = + await trx('persons') + .where({ + id: personId, + user: targetUser, + }) + .first(); + + if (!person) { + throw createHttpError( + 404, + 'Persona non trovata', + ); + } + + const face = await trx( + 'faces as f', + ) + .join( + 'photos as p', + 'p.id', + 'f.photo_id', + ) + .where({ + 'f.id': faceId, + 'p.user': targetUser, + }) + .whereNull('p.deleted_at') + .first( + 'f.id', + 'f.photo_id', + 'f.cluster_id', + 'f.person_id', + ); + + if (!face) { + throw createHttpError( + 404, + 'Volto non trovato', + ); + } + + const now = + new Date().toISOString(); + + await trx('faces') + .where({ + id: faceId, + }) + .update({ + person_id: personId, + updated_at: now, + }); + + /* + * Accetta la membership della run + * più recente che contiene il volto. + * Funziona sia per cluster sia per noise. + */ + const membership = + await trx( + 'face_cluster_memberships as m', + ) + .join( + 'face_cluster_runs as r', + 'r.id', + 'm.run_id', + ) + .where({ + 'm.face_id': faceId, + 'r.user': targetUser, + }) + .orderBy( + 'r.created_at', + 'desc', + ) + .first( + 'm.id', + 'm.run_id', + 'm.cluster_id', + 'm.label', + 'm.noise_flag', + ); + + if (membership) { + await trx( + 'face_cluster_memberships', + ) + .where({ + id: membership.id, + }) + .update({ + review_status: + 'accepted', + reviewed_by: + authenticatedUser, + reviewed_at: now, + updated_at: now, + }); + } + + /* + * Se lo stesso volto era stato + * precedentemente escluso dalla + * persona, elimina l'esclusione. + */ + await trx( + 'person_face_exclusions', + ) + .where({ + person_id: personId, + face_id: faceId, + }) + .del(); + + await queuePrototypeRefresh( + + trx, + + { + + user: targetUser, + + personId, + + reason: + + 'noise_face_assigned', + + requestedBy: + + authenticatedUser, + + }, + + ); + + + if ( + + face.person_id + + && face.person_id !== personId + + ) { + + await queuePrototypeRefresh( + + trx, + + { + + user: targetUser, + + personId: face.person_id, + + reason: + + 'face_reassigned_away', + + requestedBy: + + authenticatedUser, + + }, + + ); + + } + + + return { + person: { + id: person.id, + user: person.user, + name: person.name, + }, + face: { + id: face.id, + photo_id: face.photo_id, + cluster_id: + face.cluster_id || null, + previous_person_id: + face.person_id || null, + }, + membership: membership + ? { + run_id: + membership.run_id, + cluster_id: + membership.cluster_id + || null, + label: + Number( + membership.label, + ), + noise_flag: + Number( + membership.noise_flag, + ), + } + : null, + }; + }, + ); + + res.json({ + ok: true, + user: targetUser, + ...result, + }); + } catch (error) { + console.error( + '[faceReview] assign face:', + error, + ); + + res.status(error.status || 500) + .json({ + ok: false, + error: error.message, + }); + } + }, +); + + + + +/* + * Assegna più volti noise alla stessa persona. + * + * Body: + * { + * "person_id": "...", + * "face_ids": ["face:...", "face:..."] + * } + * + * In alternativa: + * { + * "name": "Nuova persona", + * "face_ids": ["face:...", "face:..."] + * } + */ +router.post( + '/unclustered/assign-person', + async (req, res) => { + try { + const authenticatedUser = + getAuthenticatedUser(req); + + const targetUser = + resolveFaceReviewUser(req); + + const faceIds = + normalizeFaceIds( + req.body?.face_ids, + ); + + let personId = String( + req.body?.person_id || '', + ).trim(); + + const requestedName = String( + req.body?.name || '', + ).trim(); + + if (!personId && !requestedName) { + throw createHttpError( + 400, + 'Seleziona una persona oppure ' + + 'inserisci un nuovo nome', + ); + } + + const result = await db.transaction( + async (trx) => { + let person = null; + let personCreated = false; + + if (personId) { + person = await trx('persons') + .where({ + id: personId, + user: targetUser, + }) + .first(); + + if (!person) { + throw createHttpError( + 404, + 'Persona non trovata', + ); + } + } else { + const name = + normalizePersonName( + requestedName, + ); + + person = await findPersonByName( + trx, + targetUser, + name, + ); + + if (!person) { + const now = + new Date().toISOString(); + + person = { + id: randomUUID(), + user: targetUser, + name, + created_at: now, + updated_at: now, + }; + + await trx('persons') + .insert(person); + + personCreated = true; + } + + personId = person.id; + } + + /* + * Individua la run completata più recente + * dell'utente. L'assegnazione bulk è + * consentita solo sui noise di questa run. + */ + const latestRun = await trx( + 'face_cluster_runs', + ) + .where({ + user: targetUser, + status: 'done', + }) + .orderBy( + 'completed_at', + 'desc', + ) + .orderBy( + 'created_at', + 'desc', + ) + .first(); + + if (!latestRun) { + throw createHttpError( + 409, + 'Nessuna run di clustering disponibile', + ); + } + + const rows = await trx( + 'face_cluster_memberships as m', + ) + .join( + 'faces as f', + 'f.id', + 'm.face_id', + ) + .join( + 'photos as p', + 'p.id', + 'f.photo_id', + ) + .where({ + 'm.run_id': latestRun.id, + 'm.noise_flag': 1, + 'p.user': targetUser, + }) + .whereIn( + 'm.face_id', + faceIds, + ) + .whereNull( + 'f.person_id', + ) + .select( + 'm.id as membership_id', + 'm.face_id', + 'm.label', + 'm.noise_flag', + 'f.cluster_id', + ); + + if (rows.length !== faceIds.length) { + const foundIds = new Set( + rows.map( + (row) => row.face_id, + ), + ); + + const invalidIds = + faceIds.filter( + (faceId) => + !foundIds.has(faceId), + ); + + throw createHttpError( + 400, + 'Alcuni volti non sono noise ' + + 'assegnabili della run corrente: ' + + invalidIds.join(', '), + ); + } + + const now = + new Date().toISOString(); + + for (const row of rows) { + /* + * Non modifica cluster_id: + * un noise resta senza cluster. + */ + await trx('faces') + .where({ + id: row.face_id, + }) + .update({ + person_id: personId, + updated_at: now, + }); + + await trx( + 'face_cluster_memberships', + ) + .where({ + id: row.membership_id, + }) + .update({ + review_status: 'accepted', + reviewed_by: + authenticatedUser, + reviewed_at: now, + updated_at: now, + }); + + await trx( + 'person_face_exclusions', + ) + .where({ + person_id: personId, + face_id: row.face_id, + }) + .del(); + } + + await queuePrototypeRefresh( + + trx, + + { + + user: targetUser, + + personId, + + reason: + + 'noise_faces_bulk_assigned', + + requestedBy: + + authenticatedUser, + + }, + + ); + + + return { + person: { + id: person.id, + user: person.user, + name: person.name, + }, + person_created: + personCreated, + run_id: + latestRun.id, + assigned_face_ids: + faceIds, + assigned_count: + faceIds.length, + }; + }, + ); + + res.json({ + ok: true, + user: targetUser, + ...result, + }); + } catch (error) { + console.error( + '[faceReview] bulk noise assignment:', + error, + ); + + res.status(error.status || 500) + .json({ + ok: false, + error: error.message, + }); + } + }, +); + + + + +/* + * Accoda manualmente il ricalcolo dei + * prototype di una persona. + * + * Richieste pending successive vengono + * accorpate nello stesso job. + */ +router.post( + '/persons/:personId/prototypes/recalculate', + async (req, res) => { + try { + const authenticatedUser = + getAuthenticatedUser(req); + + const targetUser = + resolveFaceReviewUser(req); + + const personId = String( + req.params.personId || '', + ).trim(); + + if (!personId) { + throw createHttpError( + 400, + 'Persona mancante', + ); + } + + const reason = String( + req.body?.reason + || 'manual_rebuild', + ).trim(); + + const result = await db.transaction( + async (trx) => { + const person = await trx( + 'persons', + ) + .where({ + id: personId, + user: targetUser, + }) + .first(); + + if (!person) { + throw createHttpError( + 404, + 'Persona non trovata', + ); + } + + const queued = + await queuePrototypeRefresh( + trx, + { + user: targetUser, + personId, + reason, + requestedBy: + authenticatedUser, + }, + ); + + return { + person: { + id: person.id, + user: person.user, + name: person.name, + }, + ...queued, + }; + }, + ); + + res.status( + result.created ? 202 : 200, + ).json({ + ok: true, + user: targetUser, + person: result.person, + created: result.created, + job: result.job, + }); + } catch (error) { + console.error( + '[faceReview] queue prototype refresh:', + error, + ); + + res.status(error.status || 500) + .json({ + ok: false, + error: error.message, + }); + } + }, +); + + + +module.exports = router; diff --git a/routes/hls.js b/routes/hls.js new file mode 100644 index 0000000..24d5383 --- /dev/null +++ b/routes/hls.js @@ -0,0 +1,260 @@ +// routes/hls.js +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const db = require('../db/knex'); +const config = require('../api_v1/config'); +const { getUserAccess, filterReadableSources } = require('../api_v1/userAccess'); +const { startJob, getJob } = require('../helpers/hlsManager'); + +const router = express.Router(); + +function countTsSegments(outDir) { + try { + return fs.readdirSync(outDir).filter( + name => name.toLowerCase().endsWith('.ts') + ).length; + } catch (err) { + return 0; + } +} + +const HLS_BASE = path.resolve(process.env.HLS_BASE || path.join(process.cwd(), 'hls_output')); +const PUBLIC_BASE_URL = config.BASE_URL || null; + +const PUBLIC_ROOT = path.resolve(__dirname, '..', 'public'); +const PHOTOS_ROOT = path.join(PUBLIC_ROOT, 'photos'); + +function safeJoin(base, ...parts) { + const resolvedBase = path.resolve(base); + const resolved = path.resolve(base, ...parts); + const isInsideBase = resolved === resolvedBase || resolved.startsWith(`${resolvedBase}${path.sep}`); + if (!isInsideBase) throw new Error(`Path non sicuro: ${resolved}`); + return resolved; +} + +// POST /hls/start +// body: { photoId } OR { inputPath } + outDir, callbackUrl, segmentsNeeded +router.post('/start', async (req, res) => { + try { + const caller = String(req.user?.name ?? '').trim(); + if (!caller) return res.status(401).json({ error: 'Unauthorized' }); + +// const { photoId, inputPath: providedPath, outDir = 'fabiocast', callbackUrl, segmentsNeeded = 10 } = req.body || {}; +const { photoId, inputPath: providedPath, outDir = 'fabiocast', callbackUrl } = req.body || {}; +const segmentsNeeded = 3; + + let inputPath = providedPath; + let photoRow = null; + + if (photoId) { + photoRow = await db('photos').where({ id: photoId }).first(); + + if (!photoRow) { + return res.status(404).json({ + error: 'Photo not found' + }); + } + + const access = getUserAccess(caller); + + if (!access) { + return res.status(403).json({ + error: 'User not configured' + }); + } + + const allowed = filterReadableSources( + access.name, + photoRow.user + ); + + if (!allowed || allowed.length === 0) { + return res.status(403).json({ + error: 'Access denied to this photo' + }); + } + + if ( + photoRow.path && + typeof photoRow.path === 'string' && + photoRow.path.trim() + ) { + const rel = + photoRow.path.replace(/^\/+/, ''); + + inputPath = safeJoin( + PUBLIC_ROOT, + rel + ); + } else { + inputPath = safeJoin( + PHOTOS_ROOT, + photoRow.user, + 'original', + photoRow.cartella || '', + photoRow.name + ); + } + } + + if (!inputPath) { + return res.status(400).json({ + error: 'Missing inputPath or photoId' + }); + } + + let jobParentDir; + + if (photoRow) { + const videoBaseName = + path.parse(photoRow.name).name; + + /* + * I file HLS sono contenuti temporanei di Cast. + * + * public/cast//videos/// + */ + jobParentDir = safeJoin( + PUBLIC_ROOT, + 'cast', + photoRow.user, + 'videos', + photoRow.cartella || '', + videoBaseName + ); + } else { + /* + * Compatibilita con vecchie chiamate basate soltanto + * su inputPath + outDir. + */ + const safeOut = + String(outDir || 'fabiocast') + .replace(/[^a-zA-Z0-9_\-\/]/g, '_'); + + jobParentDir = + path.isAbsolute(safeOut) + ? safeOut + : path.join(HLS_BASE, safeOut); + } + + const jobId = startJob({ + inputPath, + outDir: jobParentDir, + segmentsNeeded, + callbackUrl, + publicBaseUrl: PUBLIC_BASE_URL + }); + + return res.status(202).json({ jobId, outDir: jobParentDir, message: 'Transcode started' }); + } catch (err) { + console.error('Error /hls/start:', err); + return res.status(500).json({ error: err.message }); + } +}); + +// GET /hls/status/:jobId +router.get('/status/:jobId', (req, res) => { + const job = getJob(req.params.jobId); + if (!job) return res.status(404).json({ error: 'Job not found' }); + return res.json({ + jobId: job.jobId, + inputPath: job.inputPath, + outDir: job.outDir, + notified: job.notified, + useRkmpp: job.useRkmpp, + startTime: job.startTime + }); +}); + +// GET /hls/url/:jobId -> restituisce public URL completo se possibile +router.get('/url/:jobId', (req, res) => { + const job = getJob(req.params.jobId); + if (!job) return res.status(404).json({ error: 'Job not found' }); + + const masterPath = path.join( + job.outDir, + 'master.m3u8' + ); + + /* + * Il master.m3u8 puo esistere prima che sia disponibile + * un buffer sufficiente per iniziare il Cast. + * + * hlsManager imposta job.notified=true soltanto quando + * sono disponibili almeno job.segmentsNeeded segmenti. + * + * Con segmentsNeeded=3 il client puo quindi iniziare + * la riproduzione appena sono pronti i primi 3 segmenti, + * mentre FFmpeg continua la transcodifica in background. + */ + const tsCount = countTsSegments(job.outDir); + + +const masterExists = fs.existsSync(masterPath); +const enoughSegments = tsCount >= job.segmentsNeeded; + +const finishedPlayable = + job.exitCode === 0 && + job.ready && + masterExists && + tsCount >= 1; + +if ( + !masterExists || + !job.ready || + (!enoughSegments && !finishedPlayable) +) { + if (job.exitCode !== null) { + return res.status(409).json({ + error: 'HLS finished before minimum buffer was ready', + status: job.status, + ready_segments: tsCount, + required_segments: job.segmentsNeeded + }); + } + + return res.status(404).json({ + error: 'Playlist not yet ready', + ready_segments: tsCount, + required_segments: job.segmentsNeeded + }); + } + + const base = + PUBLIC_BASE_URL || + `${req.protocol}://${req.get('host')}`; + + let publicUrl; + + if ( + masterPath === PUBLIC_ROOT || + masterPath.startsWith( + `${PUBLIC_ROOT}${path.sep}` + ) + ) { + const rel = path + .relative(PUBLIC_ROOT, masterPath) + .split(path.sep) + .join('/'); + + publicUrl = + `${base.replace(/\/$/, '')}/${rel}`; + } else { + /* + * Compatibilita con eventuali vecchi job HLS + * salvati sotto HLS_BASE. + */ + const rel = path + .relative(HLS_BASE, masterPath) + .split(path.sep) + .join('/'); + + publicUrl = + `${base.replace(/\/$/, '')}/hls/${rel}`; + } + + return res.json({ jobId: job.jobId, master_m3u8: publicUrl, status: job.notified ? 'ready' : 'processing' }); +}); + +module.exports = router; diff --git a/routes/photos.js b/routes/photos.js new file mode 100644 index 0000000..762e5a3 --- /dev/null +++ b/routes/photos.js @@ -0,0 +1,1895 @@ +const express = require("express"); +const router = express.Router(); + +const path = require("path"); +const os = require("os"); +const { execFile } = require("child_process"); +const { promisify } = require("util"); +const execFileAsync = promisify(execFile); +const fsp = require("fs/promises"); +const multer = require("multer"); + +const db = require("../db/knex"); +const wss = require("../ws-server"); +const { + getUserAccess, + filterReadableSources, + canWrite, +} = require("../api_v1/userAccess"); + +// ----------------------------------------------------- +// Upload manuale foto +// ----------------------------------------------------- +const MEDIA_UPLOAD_MAX_FILES = 100; +const MEDIA_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024; + +const ALLOWED_MEDIA_EXTENSIONS = new Set([ + // Foto + ".jpg", + ".jpeg", + ".png", + ".webp", + ".gif", + ".heic", + ".heif", + ".tif", + ".tiff", + + // Video + ".mp4", + ".mov", + ".m4v", + ".webm", + ".mkv", + ".avi", + ".3gp", + ".3g2", + ".mts", + ".m2ts", + ".mpeg", + ".mpg", +]); + +const uploadMedia = multer({ + storage: multer.memoryStorage(), + + limits: { + files: MEDIA_UPLOAD_MAX_FILES, + fileSize: MEDIA_UPLOAD_MAX_BYTES, + }, + + fileFilter: (req, file, callback) => { + const extension = path.extname(file.originalname || "").toLowerCase(); + + if (!ALLOWED_MEDIA_EXTENSIONS.has(extension)) { + return callback( + new Error( + `Formato media non supportato: ${extension || "sconosciuto"}`, + ), + ); + } + + const mimeType = String(file.mimetype || "").toLowerCase(); + + const validMime = + mimeType.startsWith("image/") || + mimeType.startsWith("video/") || + mimeType === "application/octet-stream"; + + if (!validMime) { + return callback( + new Error(`Tipo MIME non supportato: ${mimeType || "sconosciuto"}`), + ); + } + + return callback(null, true); + }, +}); + +function normalizeUploadValue(value) { + return String(value ?? "").trim(); +} + +function validateDirectoryName(value, fieldName) { + const normalized = normalizeUploadValue(value); + + if (!normalized) { + throw new Error(`${fieldName} mancante`); + } + + if ( + normalized === "." || + normalized === ".." || + normalized.includes("/") || + normalized.includes("\\") || + normalized.includes("\0") + ) { + throw new Error(`${fieldName} non valido`); + } + + return normalized; +} + +function sanitizeUploadedMediaName(originalName) { + const baseName = path.basename(normalizeUploadValue(originalName)); + + if (!baseName) { + throw new Error("Nome file mancante"); + } + + const extension = path.extname(baseName).toLowerCase(); + + if (!ALLOWED_MEDIA_EXTENSIONS.has(extension)) { +throw new Error( + `Formato media non supportato: ${extension || "sconosciuto"}`, +); + } + + const nameWithoutExtension = path.basename(baseName, path.extname(baseName)); + + const safeBaseName = nameWithoutExtension + .normalize("NFKC") + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") + .replace(/\s+/g, " ") + .trim(); + + if (!safeBaseName) { + throw new Error("Nome file non valido"); + } + + return `${safeBaseName}${extension}`; +} + +async function writeUploadedFileWithoutOverwrite( + destinationDirectory, + requestedFileName, + buffer, +) { + const extension = path.extname(requestedFileName); + const baseName = path.basename(requestedFileName, extension); + + let candidateName = requestedFileName; + let counter = 1; + + while (true) { + const destinationPath = safeJoin(destinationDirectory, candidateName); + + try { + await fsp.writeFile(destinationPath, buffer, { + flag: "wx", + }); + + return { + fileName: candidateName, + filePath: destinationPath, + renamed: candidateName !== requestedFileName, + }; + } catch (err) { + if (err.code !== "EEXIST") { + throw err; + } + + candidateName = `${baseName}_${counter}${extension}`; + counter += 1; + + if (counter > 9999) { + throw new Error("Impossibile trovare un nome file disponibile"); + } + } + } +} + +// ----------------------------------------------------- +// Normalizzazione record DB → formato identico al vecchio index.json +// ----------------------------------------------------- +function safeJsonParse(value, fallback) { + try { + return value ? JSON.parse(value) : fallback; + } catch { + return fallback; + } +} + +function normalizePhoto(p) { + return { + ...p, + + gps: + p.lat != null && p.lon != null + ? { + lat: p.lat, + lng: p.lon, + alt: p.alt ?? null, + } + : null, + + location: safeJsonParse(p.location, null), + _indexHash: p._indexHash || null, + + lat: undefined, + lon: undefined, + alt: undefined, + location_json: undefined, + }; +} + +function getPhotoReadAccess(req, res) { + const loggedUser = String(req.user?.name ?? "").trim(); + + if (!loggedUser) { + res.status(401).json({ + error: "Unauthorized", + }); + + return null; + } + + const access = getUserAccess(loggedUser); + + if (!access) { + res.status(403).json({ + error: "Utente non configurato", + user: loggedUser, + }); + + return null; + } + + return access; +} + +// ----------------------------------------------------- +// File helpers hard delete foto +// ----------------------------------------------------- +const PUBLIC_ROOT = path.resolve(__dirname, "..", "public"); +const PHOTOS_ROOT = path.join(PUBLIC_ROOT, "photos"); +const DEL_PHOTOS_ROOT = path.join(PUBLIC_ROOT, "del_photos"); + +function safeJoin(base, ...parts) { + const resolvedBase = path.resolve(base); + const resolved = path.resolve(base, ...parts); + + const isInsideBase = + resolved === resolvedBase || + resolved.startsWith(`${resolvedBase}${path.sep}`); + + if (!isInsideBase) { + throw new Error(`Path non sicuro: ${resolved}`); + } + + return resolved; +} + +function getOriginalPhotoPath(row) { + return safeJoin( + PHOTOS_ROOT, + row.user, + "original", + row.cartella || "", + row.name, + ); +} + +function getDeletedPhotoPath(row) { + return safeJoin( + DEL_PHOTOS_ROOT, + row.user, + "original", + row.cartella || "", + row.name, + ); +} + +function getThumbPath(row, thumbValue) { + if (!thumbValue) return null; + + const value = String(thumbValue); + + // Caso 1: /photos/Fabio/thumbs/prime/thumb.jpg + if (value.startsWith("/photos/")) { + const relative = value.replace(/^\/+/, ""); + return safeJoin(PUBLIC_ROOT, relative); + } + + // Caso 2: photos/Fabio/thumbs/prime/thumb.jpg + if (value.startsWith("photos/")) { + return safeJoin(PUBLIC_ROOT, value); + } + + // Caso 3: solo nome file thumb.jpg + return safeJoin(PHOTOS_ROOT, row.user, "thumbs", row.cartella || "", value); +} + +async function pathExists(filePath) { + try { + await fsp.access(filePath); + return true; + } catch { + return false; + } +} + +async function moveFileSafe(src, dst) { + if (!src || !dst) return false; + + const exists = await pathExists(src); + + if (!exists) { + console.warn(`⚠️ File originale non trovato, skip move: ${src}`); + return false; + } + + // Crea automaticamente public/del_photos//original// + await fsp.mkdir(path.dirname(dst), { recursive: true }); + + try { + await fsp.rename(src, dst); + return true; + } catch (err) { + // Fallback se source/destination sono su filesystem diversi + if (err.code === "EXDEV") { + await fsp.copyFile(src, dst); + await fsp.unlink(src); + return true; + } + + throw err; + } +} + +async function deleteFileIfExists(filePath) { + if (!filePath) return false; + + try { + await fsp.unlink(filePath); + return true; + } catch (err) { + if (err.code === "ENOENT") { + return false; + } + + throw err; + } +} + +// ----------------------------------------------------- +// POST /photos +// Salva una foto inviata dallo scanner locale +// ----------------------------------------------------- +router.post("/", async (req, res) => { + const start = Date.now(); + + console.log(`📥 [POST /photos] Richiesta at=${new Date().toISOString()}`); + console.log("📥 Body:", req.body); + + try { + const p = req.body; + + const loggedUser = String(req.user?.name ?? "").trim(); + const targetSource = String(p.user ?? "").trim(); + + if (!loggedUser) { + return res.status(401).json({ + ok: false, + error: "Unauthorized", + }); + } + + if (!targetSource) { + return res.status(400).json({ + ok: false, + error: "Missing photo user/source", + }); + } + + if (!canWrite(loggedUser, targetSource)) { + console.warn("⛔ Inserimento foto non autorizzato", { + loggedUser, + targetSource, + id: p.id, + }); + + return res.status(403).json({ + ok: false, + error: "Write not allowed", + targetSource, + }); + } + + console.log(`📸 [DB INSERT] Salvo foto id=${p.id} user=${p.user}`); + + await db("photos") + .insert({ + id: p.id, + user: p.user, + cartella: p.cartella, + name: p.name, + path: p.path, + thub1: p.thub1 || "", + thub2: p.thub2 || "", + cast: p.cast || "", + mime_type: p.mime_type, + width: p.width, + height: p.height, + rotation: p.rotation, + size_bytes: p.size_bytes, + mtimeMs: p.mtimeMs, + duration_ms: p.duration_ms, + taken_at: p.taken_at, + data: p.data, + lat: p.gps?.lat ?? null, + lon: p.gps?.lng ?? null, + alt: p.gps?.alt ?? null, + location: p.location ? JSON.stringify(p.location) : null, + _indexHash: p._indexHash, + fast_hash: p.fast_hash, + deleted_at: p.deleted_at || null, + + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .onConflict("id") + .merge(); + + wss.broadcastToUser(p.user, { + type: "added", + id: p.id, + entity: "photo", + }); + + console.log( + `✅ [POST /photos] Foto id=${p.id} salvata durata=${Date.now() - start}ms`, + ); + + res.json({ ok: true }); + } catch (err) { + console.error("❌ Errore POST /photos:", err); + res.status(500).json({ error: err.message }); + } +}); + +// ----------------------------------------------------- +// GET /photos/changes +// Progressive sync ADD + UPDATE +// Mostra solamente usersR + devices +// ----------------------------------------------------- +router.get("/changes", async (req, res) => { + const access = getPhotoReadAccess(req, res); + + if (!access) { + return; + } + + const since = req.query.since; + const start = Date.now(); + + console.log( + `🔄 [SYNC SINCE] GET /photos/changes user=${access.name} since=${since} at=${new Date().toISOString()}`, + ); + + console.log("🔐 Sorgenti leggibili:", access.readableSources); + + if (!since) { + console.log("⚠️ [SYNC SINCE] Richiesta senza ?since"); + + return res.status(400).json({ + error: "Missing ?since=timestamp", + }); + } + + if (access.readableSources.length === 0) { + return res.json([]); + } + + try { + const rows = await db("photos") + .whereIn("user", access.readableSources) + .andWhere((builder) => { + builder + .where("created_at", ">", since) + .orWhere("updated_at", ">", since); + }) + .orderBy("updated_at", "desc"); + + console.log( + `🔄 [SYNC SINCE] Risposta: ${rows.length} cambiamenti durata=${Date.now() - start}ms`, + ); + + return res.json(rows.map(normalizePhoto)); + } catch (err) { + console.error("❌ Errore GET /photos/changes:", err); + + return res.status(500).json({ + error: err.message, + }); + } +}); + +// ----------------------------------------------------- +// GET /photos/byIds +// Restituisce solo ID appartenenti a sorgenti leggibili +// ----------------------------------------------------- +router.get("/byIds", async (req, res) => { + const access = getPhotoReadAccess(req, res); + + if (!access) { + return; + } + + const ids = req.query.id; + const requestedUsers = req.query.user; + const start = Date.now(); + + console.log(`📤 [GET /photos/byIds] at=${new Date().toISOString()}`); + console.log("📤 ids:", ids); + console.log("📤 requestedUsers:", requestedUsers); + console.log("🔐 readableSources:", access.readableSources); + + if (!ids) { + return res.json([]); + } + + const idList = Array.isArray(ids) ? ids : [ids]; + + const allowedSources = filterReadableSources(access.name, requestedUsers); + + console.log("🔐 allowedSources:", allowedSources); + + if (allowedSources.length === 0) { + return res.json([]); + } + + try { + const rows = await db("photos") + .whereIn("id", idList) + .whereIn("user", allowedSources); + + console.log( + `📤 [GET /photos/byIds] trovate ${rows.length} foto durata=${Date.now() - start}ms`, + ); + + return res.json(rows.map(normalizePhoto)); + } catch (err) { + console.error("❌ Errore /photos/byIds:", err); + + return res.status(500).json({ + error: err.message, + }); + } +}); + +// ----------------------------------------------------- + +// ----------------------------------------------------- + +// ----------------------------------------------------- +// GET /photos/map +// Restituisce solo le foto geolocalizzate nell'area richiesta. +router.get("/map", async (req, res) => { + const access = getPhotoReadAccess(req, res); + if (!access) return; + + if (!access.readableSources.length) { + return res.json({ items: [], limit: 2500, truncated: false }); + } + + const west = Number(req.query.west); + const east = Number(req.query.east); + const south = Number(req.query.south); + const north = Number(req.query.north); + + if ( + ![west, east, south, north].every(Number.isFinite) || + west < -180 || west > 180 || + east < -180 || east > 180 || + south < -90 || north > 90 || + south > north + ) { + return res.status(400).json({ error: "Limiti geografici non validi" }); + } + + const requestedLimit = Number.parseInt(req.query.limit, 10); + const limit = Number.isFinite(requestedLimit) + ? Math.max(1, Math.min(requestedLimit, 5000)) + : 2500; + + const includeDeleted = ["1", "true", "yes"].includes( + String(req.query.showDeleted || "").toLowerCase() + ); + + try { + let query = db("photos") + .whereIn("user", access.readableSources) + .whereNotNull("lat") + .whereNotNull("lon") + .whereBetween("lat", [south, north]); + + if (!includeDeleted) { + query = query.whereNull("deleted_at"); + } + + if (west <= east) { + query = query.whereBetween("lon", [west, east]); + } else { + query = query.where(function () { + this.where("lon", ">=", west).orWhere("lon", "<=", east); + }); + } + + const rows = await query.limit(limit + 1); + const truncated = rows.length > limit; + const page = truncated ? rows.slice(0, limit) : rows; + + return res.json({ + items: page.map(normalizePhoto), + limit, + truncated, + }); + } catch (err) { + console.error("Errore GET /photos/map:", err); + return res.status(500).json({ + error: "Errore caricamento foto della mappa", + }); + } +}); + +// GET /photos/gallery/options +// Valori dei filtri senza caricare tutte le foto nel browser. +// ----------------------------------------------------- +router.get("/gallery/options", async (req, res) => { + const access = getPhotoReadAccess(req, res); + if (!access) return; + + if (!access.readableSources.length) { + return res.json({ years: [], folders: [] }); + } + + try { + const includeDeleted = ["1", "true", "yes"].includes( + String(req.query.showDeleted || "").toLowerCase() + ); + + const base = () => { + let query = db("photos") + .whereIn("user", access.readableSources); + + if (!includeDeleted) { + query = query.whereNull("deleted_at"); + } + + return query; + }; + + const yearRows = await base() + .whereNotNull("taken_at") + .select(db.raw("DISTINCT strftime('%Y', taken_at) AS year")) + .orderBy("year", "desc"); + + const folderRows = await base() + .whereNotNull("cartella") + .whereNot("cartella", "") + .distinct("cartella") + .orderBy("cartella", "asc") + .pluck("cartella"); + + return res.json({ + years: yearRows + .map(row => String(row.year || "")) + .filter(year => /^\d{4}$/.test(year)), + folders: folderRows + .map(folder => String(folder || "").trim()) + .filter(Boolean), + }); + } catch (err) { + console.error("Errore GET /photos/gallery/options:", err); + return res.status(500).json({ + error: "Errore caricamento opzioni galleria", + }); + } +}); + +// GET /photos/gallery +// Lettura paginata della galleria: separata dalla full sync. +// ----------------------------------------------------- +router.get("/gallery", async (req, res) => { + const access = getPhotoReadAccess(req, res); + if (!access) return; + + if (!access.readableSources.length) { + return res.json({ + items: [], + nextCursor: null, + hasMore: false, + }); + } + + try { + const requestedLimit = Number.parseInt(req.query.limit, 10); + const limit = Number.isFinite(requestedLimit) + ? Math.max(1, Math.min(requestedLimit, 100)) + : 40; + + const sort = String(req.query.sort || "desc").toLowerCase() === "asc" + ? "asc" + : "desc"; + + const cursorText = String(req.query.cursor || ""); + let cursor = null; + + if (cursorText) { + try { + const decoded = JSON.parse( + Buffer.from(cursorText, "base64url").toString("utf8") + ); + + if ( + decoded && + typeof decoded.takenAt === "string" && + typeof decoded.id === "string" + ) { + cursor = decoded; + } else { + return res.status(400).json({ error: "Cursore non valido" }); + } + } catch { + return res.status(400).json({ error: "Cursore non valido" }); + } + } + + let query = db("photos") + .whereIn("user", access.readableSources); + + // Per impostazione predefinita la galleria esclude gli elementi eliminati. + const includeDeleted = ["1", "true", "yes"].includes( + String(req.query.showDeleted || "").toLowerCase() + ); + + if (!includeDeleted) { + query = query.whereNull("deleted_at"); + } + + const search = String(req.query.search || "").trim(); + if (search) { + const pattern = `%${search}%`; + query = query.where(function () { + this.where("name", "like", pattern) + .orWhere("path", "like", pattern) + .orWhere("cartella", "like", pattern); + }); + } + + const folder = String(req.query.folder || "").trim(); + if (folder) { + query = query.where("cartella", folder); + } + + const year = String(req.query.year || "").trim(); + if (/^\d{4}$/.test(year)) { + query = query.whereRaw("strftime('%Y', taken_at) = ?", [year]); + } + + const month = String(req.query.month || "").trim(); + if (/^(0?[1-9]|1[0-2])$/.test(month)) { + query = query.whereRaw( + "CAST(strftime('%m', taken_at) AS INTEGER) = ?", + [Number(month)] + ); + } + + const filter = String(req.query.filter || "").toLowerCase(); + + if (filter === "folder") { + query = query.whereNotNull("cartella").whereNot("cartella", ""); + } else if (filter === "location") { + query = query.whereNotNull("lat").whereNotNull("lon"); + } else if (filter === "images" || filter === "type") { + query = query.where("mime_type", "like", "image/%"); + } else if (filter === "videos") { + query = query.where("mime_type", "like", "video/%"); + } + + // Ordinamento stabile anche quando più foto hanno la stessa data. + // COALESCE include anche gli elementi senza taken_at. + if (cursor) { + const comparison = sort === "asc" ? ">" : "<"; + + query = query.whereRaw( + `(COALESCE(taken_at, '') ${comparison} ? OR + (COALESCE(taken_at, '') = ? AND id ${comparison} ?))`, + [cursor.takenAt, cursor.takenAt, cursor.id] + ); + } + + const rows = await query + .orderByRaw(`COALESCE(taken_at, '') ${sort.toUpperCase()}`) + .orderBy("id", sort) + .limit(limit + 1); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + + let nextCursor = null; + if (hasMore && page.length) { + const last = page[page.length - 1]; + nextCursor = Buffer.from(JSON.stringify({ + takenAt: String(last.taken_at || ""), + id: String(last.id), + })).toString("base64url"); + } + + return res.json({ + items: page.map(normalizePhoto), + nextCursor, + hasMore, + limit, + }); + } catch (err) { + console.error("Errore GET /photos/gallery:", err); + return res.status(500).json({ error: "Errore caricamento galleria" }); + } +}); + +// GET /photos +// Full sync +// Mostra solamente usersR + devices +// ----------------------------------------------------- +router.get("/", async (req, res) => { + const access = getPhotoReadAccess(req, res); + + if (!access) { + return; + } + + const start = Date.now(); + + console.log( + `📤 [FULL SYNC] GET /photos da=${access.name} at=${new Date().toISOString()}`, + ); + + console.log("🔐 Sorgenti leggibili:", access.readableSources); + + if (access.readableSources.length === 0) { + return res.json([]); + } + + try { + const rows = await db("photos") + .whereIn("user", access.readableSources) + .orderBy("mtimeMs", "desc"); + + console.log( + `📤 [FULL SYNC] Risposta: ${rows.length} foto durata=${Date.now() - start}ms`, + ); + + return res.json(rows.map(normalizePhoto)); + } catch (err) { + console.error("❌ Errore GET /photos:", err); + + return res.status(500).json({ + error: err.message, + }); + } +}); + +// ----------------------------------------------------- +// POST /photos/initDB +// Svuota dal DB tutte le sorgenti scrivibili dall'utente +// +// Admin: +// Common, Fabio, Jessica, Fabio13TPro, Fabio11TPro +// +// Fabio: +// Fabio, Fabio13TPro, Fabio11TPro +// +// Jessica: +// Jessica +// ----------------------------------------------------- +router.post("/initDB", async (req, res) => { + const loggedUser = String(req.user?.name ?? "").trim(); + const start = Date.now(); + + try { + if (!loggedUser) { + return res.status(401).json({ + ok: false, + error: "Unauthorized", + }); + } + + const access = getUserAccess(loggedUser); + + if (!access) { + return res.status(403).json({ + ok: false, + error: "Utente non configurato", + user: loggedUser, + }); + } + + const writableSources = access.writableSources; + + console.log("🧹 [POST /photos/initDB]", { + requestedBy: loggedUser, + writableSources, + }); + + if (!Array.isArray(writableSources) || writableSources.length === 0) { + return res.json({ + ok: true, + scope: "none", + user: loggedUser, + deletedPhotos: 0, + deletedHardRecords: 0, + writableSources: [], + duration_ms: Date.now() - start, + message: "Nessuna sorgente scrivibile da svuotare", + }); + } + + const result = await db.transaction(async (trx) => { + const deletedPhotos = await trx("photos") + .whereIn("user", writableSources) + .del(); + + const deletedHardRecords = await trx("deleted_hard") + .whereIn("user", writableSources) + .del(); + + return { + deletedPhotos, + deletedHardRecords, + }; + }); + + console.log("✅ [POST /photos/initDB] completato", { + requestedBy: loggedUser, + writableSources, + deletedPhotos: result.deletedPhotos, + deletedHardRecords: result.deletedHardRecords, + duration_ms: Date.now() - start, + }); + + return res.json({ + ok: true, + scope: "writableSources", + user: loggedUser, + writableSources, + deletedPhotos: result.deletedPhotos, + deletedHardRecords: result.deletedHardRecords, + duration_ms: Date.now() - start, + message: `DB foto svuotato per le sorgenti scrivibili di ${loggedUser}`, + }); + } catch (err) { + console.error("❌ Errore POST /photos/initDB:", err); + + return res.status(500).json({ + ok: false, + error: err.message, + duration_ms: Date.now() - start, + }); + } +}); + +// ----------------------------------------------------- +// POST /photos/initDBuser +// Reset DB foto per utente specifico +// Solo Admin +// ----------------------------------------------------- +router.post("/initDBuser", async (req, res) => { + try { + const loggedUser = req.user?.name; + + if (loggedUser !== "Admin") { + return res.status(403).json({ + ok: false, + error: "Forbidden", + loggedUser, + }); + } + + const targetUser = + req.body?.user || req.query?.user || req.body?.name || req.query?.name; + + if (!targetUser) { + return res.status(400).json({ + ok: false, + error: "Missing user", + }); + } + + console.log( + `🧹 [POST /photos/initDBuser] Admin=${loggedUser} target=${targetUser}`, + ); + + await db("photos").where({ user: targetUser }).del(); + + await db("deleted_hard").where({ user: targetUser }).del(); + + res.json({ + ok: true, + user: targetUser, + message: `DB foto svuotato per user=${targetUser}`, + }); + } catch (err) { + console.error("❌ Errore POST /photos/initDBuser:", err); + + res.status(500).json({ + ok: false, + error: err.message, + }); + } +}); + +// ----------------------------------------------------- +// GET /photos/deleted_hard +// Restituisce solamente eliminazioni leggibili +// Deve stare PRIMA di /:id +// ----------------------------------------------------- +router.get("/deleted_hard", async (req, res) => { + const access = getPhotoReadAccess(req, res); + + if (!access) { + return; + } + + const since = req.query.since; + + if (!since) { + return res.status(400).json({ + error: "Missing ?since=timestamp", + }); + } + + if (access.readableSources.length === 0) { + return res.json({ + deleted: [], + }); + } + + try { + const rows = await db("deleted_hard") + .whereIn("user", access.readableSources) + .andWhere("deleted_at", ">", since) + .orderBy("deleted_at", "asc"); + + return res.json({ + deleted: rows, + }); + } catch (err) { + console.error("❌ Errore GET /photos/deleted_hard:", err); + + return res.status(500).json({ + error: err.message, + }); + } +}); + +// ----------------------------------------------------- +// POST /photos/:id/toggle-soft +// Toggle soft delete foto per ID +// - Se deleted_at è null → soft delete +// - Se deleted_at è valorizzato → restore +// ----------------------------------------------------- +router.post("/:id/toggle-soft", async (req, res) => { + const id = req.params.id; + const loggedUser = String(req.user?.name ?? "").trim(); + const start = Date.now(); + + try { + if (!loggedUser) { + return res.status(401).json({ + ok: false, + error: "Unauthorized", + }); + } + + console.log(`🔁 [TOGGLE SOFT /photos/${id}] richiesto da=${loggedUser}`); + + const row = await db("photos").where({ id }).first(); + + if (!row) { + return res.status(404).json({ + ok: false, + error: "Photo not found", + id, + }); + } + + if (!canWrite(loggedUser, row.user)) { + console.warn("⛔ Toggle soft non autorizzato", { + loggedUser, + photoUser: row.user, + id, + }); + + return res.status(404).json({ + ok: false, + error: "Photo not found or not allowed", + id, + }); + } + + const now = new Date().toISOString(); + const nextDeletedAt = row.deleted_at ? null : now; + + await db("photos").where({ id }).update({ + deleted_at: nextDeletedAt, + updated_at: now, + }); + + try { + wss.broadcastToUser(row.user, { + type: "updated", + id, + entity: "photo", + deleted_at: nextDeletedAt, + }); + } catch (e) { + console.warn("⚠️ WS broadcast photo toggle soft fallito:", e.message); + } + + console.log( + `✅ [TOGGLE SOFT /photos/${id}] user=${row.user} ` + + `soft_deleted=${!!nextDeletedAt} ` + + `durata=${Date.now() - start}ms`, + ); + + return res.json({ + ok: true, + id, + user: row.user, + deleted_at: nextDeletedAt, + soft_deleted: Boolean(nextDeletedAt), + restored: !nextDeletedAt, + duration_ms: Date.now() - start, + }); + } catch (err) { + console.error("❌ Errore POST /photos/:id/toggle-soft:", err); + + return res.status(500).json({ + ok: false, + error: err.message, + }); + } +}); + +// ----------------------------------------------------- +// DELETE /photos/:id +// HARD DELETE foto per ID +// - Sposta originale in public/del_photos//original// +// - Elimina thub1 e thub2 +// - Rimuove record da photos +// - Registra ID in deleted_hard +// ----------------------------------------------------- +router.delete("/:id", async (req, res) => { + const id = req.params.id; + const loggedUser = req.user?.name; + const start = Date.now(); + + try { + if (!loggedUser) { + return res.status(401).json({ + ok: false, + error: "Unauthorized", + }); + } + + console.log(`🗑️ [HARD DELETE /photos/${id}] richiesto da=${loggedUser}`); + + const row = await db("photos").where({ id }).first(); + + if (!row) { + return res.status(404).json({ + ok: false, + error: "Photo not found", + id, + }); + } + + if (!canWrite(loggedUser, row.user)) { + return res.status(404).json({ + ok: false, + error: "Photo not found or not allowed", + id, + }); + } + + const deletedAt = new Date().toISOString(); + + // 1. Sposta originale + const originalSrc = getOriginalPhotoPath(row); + const originalDst = getDeletedPhotoPath(row); + const originalMoved = await moveFileSafe(originalSrc, originalDst); + + // 2. Elimina thumbs + const thumb1Path = getThumbPath(row, row.thub1); + const thumb2Path = getThumbPath(row, row.thub2); + const castPath = getThumbPath(row, row.cast); + + const thumb1Deleted = await deleteFileIfExists(thumb1Path); + const thumb2Deleted = await deleteFileIfExists(thumb2Path); + const castDeleted = await deleteFileIfExists(castPath); + + // 3. Elimina record da photos + await db("photos").where({ id }).del(); + + // 4. Registra hard delete + await db("deleted_hard") + .insert({ + id, + user: row.user, + deleted_at: deletedAt, + }) + .onConflict("id") + .merge(); + + // 5. WS realtime + try { + wss.broadcastToUser(row.user, { + type: "removed", + id, + entity: "photo", + }); + } catch (e) { + console.warn("⚠️ WS broadcast photo hard delete fallito:", e.message); + } + + console.log( + `✅ [HARD DELETE /photos/${id}] user=${row.user} durata=${Date.now() - start}ms`, + ); + + res.json({ + ok: true, + id, + user: row.user, + deleted_at: deletedAt, + hard_deleted: true, + files: { + original_src: originalSrc, + original_dst: originalDst, + original_moved: originalMoved, + thumb1: thumb1Path, + thumb1_deleted: thumb1Deleted, + thumb2: thumb2Path, + thumb2_deleted: thumb2Deleted, + cast: castPath, + cast_deleted: castDeleted, + }, + duration_ms: Date.now() - start, + }); + } catch (err) { + console.error("❌ Errore HARD DELETE /photos/:id:", err); + + res.status(500).json({ + ok: false, + error: err.message, + }); + } +}); + +// ----------------------------------------------------- +// GET /photos/upload-targets +// Restituisce le destinazioni disponibili per upload: +// - utente autenticato +// - device associati all'utente +// ----------------------------------------------------- +router.get("/upload-targets", async (req, res) => { + try { + const loggedUser = normalizeUploadValue(req.user?.name); + + if (!loggedUser) { + return res.status(401).json({ + ok: false, + error: "Unauthorized", + }); + } + + const access = getUserAccess(loggedUser); + + if (!access) { + return res.status(403).json({ + ok: false, + error: "Utente non configurato", + user: loggedUser, + }); + } + + const candidates = [ + { + value: access.name, + label: access.name, + type: "user", + }, + + ...access.devices.map((device) => ({ + value: device, + label: device, + type: "device", + })), + ]; + + const targets = candidates.filter((target) => { + return canWrite(loggedUser, target.value); + }); + + return res.json({ + ok: true, + user: access.name, + targets, + }); + } catch (err) { + console.error("❌ Errore GET /photos/upload-targets:", err); + + return res.status(500).json({ + ok: false, + error: err.message, + }); + } +}); + +// ----------------------------------------------------- +// GET /photos/upload-folders?source= +// Elenca solo le cartelle reali dell'utente autenticato. +// ----------------------------------------------------- +router.get("/upload-folders", async (req, res) => { + try { + const loggedUser = normalizeUploadValue(req.user?.name); + + if (!loggedUser) { + return res.status(401).json({ ok: false, error: "Unauthorized" }); + } + + const access = getUserAccess(loggedUser); + + if (!access) { + return res.status(403).json({ + ok: false, + error: "Utente non configurato", + }); + } + + const sourceName = validateDirectoryName(req.query?.source, "Sorgente"); + + // Questo endpoint elenca esclusivamente le cartelle dell'utente, + // non quelle dei device associati. + if (sourceName.toLowerCase() !== String(access.name).toLowerCase()) { + return res.status(403).json({ + ok: false, + error: "È possibile elencare solo le cartelle del proprio utente", + }); + } + + if (!canWrite(loggedUser, access.name)) { + return res.status(403).json({ + ok: false, + error: "Write not allowed", + }); + } + + const originalDirectory = safeJoin( + PHOTOS_ROOT, + access.name, + "original" + ); + + let entries; + + try { + entries = await fsp.readdir(originalDirectory, { + withFileTypes: true, + }); + } catch (err) { + if (err.code === "ENOENT") { + return res.json({ ok: true, source: access.name, folders: [] }); + } + throw err; + } + + const folders = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b, "it")); + + return res.json({ + ok: true, + source: access.name, + folders, + }); + } catch (err) { + console.error("Errore GET /photos/upload-folders:", err); + + return res.status(400).json({ + ok: false, + error: err.message || "Impossibile elencare le cartelle", + }); + } +}); + +// ----------------------------------------------------- +// POST /photos/upload +// +// multipart/form-data: +// - source: utente o device di destinazione +// - folder: nome cartella +// - photo: file della foto +// +// Salva in: +// public/photos//original// +// ----------------------------------------------------- +router.post( + "/upload", + uploadMedia.array("photo", MEDIA_UPLOAD_MAX_FILES), + async (req, res) => { + const start = Date.now(); + + try { + const loggedUser = normalizeUploadValue(req.user?.name); + + if (!loggedUser) { + return res.status(401).json({ + ok: false, + error: "Unauthorized", + }); + } + + const access = getUserAccess(loggedUser); + + if (!access) { + return res.status(403).json({ + ok: false, + error: "Utente non configurato", + user: loggedUser, + }); + } + + const source = validateDirectoryName(req.body?.source, "Sorgente"); + + const folder = validateDirectoryName(req.body?.folder, "Cartella"); + + const uploadedFiles = Array.isArray(req.files) ? req.files : []; + + if (uploadedFiles.length === 0) { + return res.status(400).json({ + ok: false, + error: "Missing media files", + }); + } + + /* + * Il menu deve contenere esclusivamente: + * - l'utente autenticato + * - i device associati all'utente + * + * Non basta fidarsi del menu del client, perché la richiesta + * HTTP può essere costruita manualmente. + */ + const allowedUploadTargets = new Set( + [access.name, ...access.devices].map((value) => { + return String(value).toLowerCase(); + }), + ); + + if (!allowedUploadTargets.has(source.toLowerCase())) { + console.warn("⛔ Destinazione upload non assegnata", { + loggedUser, + source, + }); + + return res.status(403).json({ + ok: false, + error: "Upload target not assigned to user", + source, + }); + } + + /* + * Secondo controllo centralizzato. + * La sorgente deve anche essere presente fra quelle scrivibili. + */ + if (!canWrite(loggedUser, source)) { + console.warn("⛔ Upload media non autorizzato", { + loggedUser, + source, + }); + + return res.status(403).json({ + ok: false, + error: "Write not allowed", + source, + }); + } + + /* + * Estensioni usate per riconoscere i video quando il browser + * trasmette un MIME generico, per esempio application/octet-stream. + */ + const videoExtensions = new Set([ + ".mp4", + ".mov", + ".m4v", + ".webm", + ".mkv", + ".avi", + ".3gp", + ".3g2", + ".mts", + ".m2ts", + ".mpeg", + ".mpg", + ]); + + /* + * Prima validiamo e prepariamo tutti i file. + * Se un nome o un'estensione non è valido, nessun file viene scritto. + */ + const preparedFiles = uploadedFiles.map((file) => { + const fileName = sanitizeUploadedMediaName(file.originalname); + + const extension = path.extname(fileName).toLowerCase(); + + const mimeType = String(file.mimetype || "").toLowerCase(); + + const mediaType = mimeType.startsWith("video/") + ? "video" + : mimeType.startsWith("image/") + ? "photo" + : videoExtensions.has(extension) + ? "video" + : "photo"; + + return { + file, + fileName, + extension, + mimeType, + mediaType, + }; + }); + + /* + * Cartella finale: + * public/photos//original// + */ + const destinationDirectory = safeJoin( + PHOTOS_ROOT, + source, + "original", + folder, + ); + + /* + * Crea la cartella se non esiste. + */ + await fsp.mkdir(destinationDirectory, { + recursive: true, + }); + + const savedFiles = []; + + /* + * Salva tutti i file in sequenza. + * + * writeUploadedFileWithoutOverwrite() evita di sovrascrivere + * un file esistente e, se necessario, aggiunge _1, _2, ecc. + */ + for (const prepared of preparedFiles) { + const saved = await writeUploadedFileWithoutOverwrite( + destinationDirectory, + prepared.fileName, + prepared.file.buffer, + ); + + const savedFile = { + mediaType: prepared.mediaType, + mimeType: prepared.mimeType, + fileName: saved.fileName, + originalFileName: prepared.file.originalname, + renamed: saved.renamed, + sizeBytes: prepared.file.size, + }; + + savedFiles.push(savedFile); + + console.log("✅ [POST /photos/upload] Media salvato", { + requestedBy: loggedUser, + source, + folder, + mediaType: prepared.mediaType, + mimeType: prepared.mimeType, + originalName: prepared.file.originalname, + savedName: saved.fileName, + filePath: saved.filePath, + sizeBytes: prepared.file.size, + renamed: saved.renamed, + }); + } + + /* + * Riepilogo dell'upload. + */ + const totalSizeBytes = savedFiles.reduce( + (total, file) => total + file.sizeBytes, + 0, + ); + + const photoCount = savedFiles.filter((file) => { + return file.mediaType === "photo"; + }).length; + + const videoCount = savedFiles.filter((file) => { + return file.mediaType === "video"; + }).length; + + console.log("✅ [POST /photos/upload] Upload completato", { + requestedBy: loggedUser, + source, + folder, + fileCount: savedFiles.length, + photoCount, + videoCount, + totalSizeBytes, + destinationDirectory, + durationMs: Date.now() - start, + }); + + /* + * Non viene inserito nulla direttamente nel DB. + * Il watcher rileverà e indicizzerà i nuovi media. + */ + return res.status(201).json({ + ok: true, + source, + folder, + fileCount: savedFiles.length, + photoCount, + videoCount, + totalSizeBytes, + files: savedFiles, + watcherPending: true, + duration_ms: Date.now() - start, + }); + } catch (err) { + console.error("❌ Errore POST /photos/upload:", err); + + return res.status(400).json({ + ok: false, + error: err.message, + duration_ms: Date.now() - start, + }); + } + }, +); +// ----------------------------------------------------- + +// ----------------------------------------------------- +// POST /photos/download +// Scarica un originale oppure uno ZIP di originali. +// Accetta esclusivamente ID, mai percorsi filesystem. +// ----------------------------------------------------- +router.post("/download", async (req, res) => { + const access = getPhotoReadAccess(req, res); + if (!access) return; + + const rawIds = req.body?.ids; + if (!Array.isArray(rawIds) || rawIds.length < 1 || rawIds.length > 500) { + return res.status(400).json({ + error: "Invia da 1 a 500 ID di foto." + }); + } + + const ids = [...new Set( + rawIds + .filter(id => typeof id === "string" || typeof id === "number") + .map(id => String(id).trim()) + .filter(Boolean) + )]; + + if (!ids.length || ids.length !== rawIds.length) { + return res.status(400).json({ error: "Elenco ID non valido." }); + } + + if (!access.readableSources.length) { + return res.status(403).json({ error: "Nessuna foto accessibile." }); + } + + let tempDir = null; + let cleanupStarted = false; + + const cleanup = async () => { + if (cleanupStarted) return; + cleanupStarted = true; + if (tempDir) { + await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } + }; + + try { + const rows = await db("photos") + .whereIn("id", ids) + .whereIn("user", access.readableSources); + + // Non rivelare se un ID esiste ma appartiene a un altro utente. + if (rows.length !== ids.length) { + return res.status(404).json({ + error: "Una o più foto non sono disponibili." + }); + } + + const byId = new Map(rows.map(row => [String(row.id), row])); + const orderedRows = ids.map(id => byId.get(id)); + + const files = []; + for (const row of orderedRows) { + // Il soft delete aggiorna solo deleted_at: l'originale resta + // nella cartella photos. Solo l'hard delete sposta il file. + const filePath = getOriginalPhotoPath(row); + + const stat = await fsp.stat(filePath).catch(() => null); + if (!stat?.isFile()) { + return res.status(404).json({ + error: "Uno o più file originali non sono disponibili." + }); + } + + files.push({ row, filePath }); + } + + const safeFilename = (name) => { + const base = path.basename(String(name || "foto")) + .replace(/[\u0000-\u001f\u007f]/g, "_") + .replace(/[\\/:"*?<>|]/g, "_") + .trim(); + return base || "foto"; + }; + + if (files.length === 1) { + const { row, filePath } = files[0]; + return res.download(filePath, safeFilename(row.name), err => { + if (err && !res.headersSent) { + res.status(500).json({ error: "Download non riuscito." }); + } + }); + } + + tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "photo-download-")); + const archivePath = path.join(tempDir, "foto-selezionate.zip"); + const stagedFiles = []; + + // Prefisso numerico: evita collisioni quando più cartelle contengono + // file con lo stesso nome. + for (let i = 0; i < files.length; i++) { + const { row, filePath } = files[i]; + const stagedName = + `${String(i + 1).padStart(4, "0")}_${safeFilename(row.name)}`; + const stagedPath = path.join(tempDir, stagedName); + await fsp.copyFile(filePath, stagedPath); + stagedFiles.push(stagedPath); + } + + // Eseguire zip con argomenti separati, senza shell e senza interpolazioni. + for (const stagedPath of stagedFiles) { + await execFileAsync("/usr/bin/zip", [ + "-q", "-j", archivePath, stagedPath + ]); + } + + const archiveStat = await fsp.stat(archivePath).catch(() => null); + if (!archiveStat?.isFile() || archiveStat.size === 0) { + await cleanup(); + return res.status(500).json({ error: "Creazione ZIP non riuscita." }); + } + + res.on("close", () => { void cleanup(); }); + return res.download(archivePath, "foto-selezionate.zip", async err => { + await cleanup(); + if (err && !res.headersSent) { + res.status(500).json({ error: "Download ZIP non riuscito." }); + } + }); + } catch (err) { + await cleanup(); + console.error("Errore POST /photos/download:", err); + if (!res.headersSent) { + return res.status(500).json({ error: "Errore durante il download." }); + } + } +}); + +// GET /photos/:id +// Deve stare DOPO tutte le route con percorso specifico +// ----------------------------------------------------- +router.get("/:id", async (req, res) => { + const access = getPhotoReadAccess(req, res); + + if (!access) { + return; + } + + const id = req.params.id; + const start = Date.now(); + + console.log( + `📤 [GET /photos/${id}] Richiesta da=${access.name} at=${new Date().toISOString()}`, + ); + + if (access.readableSources.length === 0) { + return res.json([]); + } + + try { + const rows = await db("photos") + .where({ id }) + .whereIn("user", access.readableSources); + + console.log( + `📤 [GET /photos/${id}] Risposta: ${rows.length} foto durata=${Date.now() - start}ms`, + ); + + if (!rows.length) { + return res.json([]); + } + + return res.json(rows.map(normalizePhoto)); + } catch (err) { + console.error("❌ Errore GET /photos/:id:", err); + + return res.status(500).json({ + error: err.message, + }); + } +}); + +// ----------------------------------------------------- +// Gestione errori upload Multer +// Deve stare dopo tutte le route e prima di module.exports +// ----------------------------------------------------- +router.use((err, req, res, next) => { + if (err instanceof multer.MulterError) { + console.error("❌ Errore Multer upload media:", err); + + if (err.code === "LIMIT_FILE_SIZE") { + return res.status(413).json({ + ok: false, + error: "Uno dei file supera la dimensione massima", + maxBytesPerFile: MEDIA_UPLOAD_MAX_BYTES, + }); + } + + if (err.code === "LIMIT_FILE_COUNT") { + return res.status(400).json({ + ok: false, + error: "Troppi file selezionati", + maxFiles: MEDIA_UPLOAD_MAX_FILES, + }); + } + + if (err.code === "LIMIT_UNEXPECTED_FILE") { + return res.status(400).json({ + ok: false, + error: + 'Campo file non valido o troppi file. Usare il campo "photo".', + }); + } + + return res.status(400).json({ + ok: false, + error: err.message, + code: err.code, + }); + } + + if (err) { + console.error("❌ Errore upload media:", err); + + return res.status(400).json({ + ok: false, + error: err.message, + }); + } + + return next(); +}); + +module.exports = router; + +module.exports = router; diff --git a/routes/scan.js b/routes/scan.js new file mode 100644 index 0000000..73b6ee8 --- /dev/null +++ b/routes/scan.js @@ -0,0 +1,49 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const scanPhotosUser = require('../api_v1/scanner/scanPhotosUser'); +const photoScanProgress = require('../api_v1/scanner/photoScanProgress'); +const db = require('../db/knex'); +const fs = require('fs'); + +const userdb = JSON.parse(fs.readFileSync('./api_v1/users.json', 'utf-8')); + +router.get('/', async (req, res) => { + let scanStarted = false; + + try { + await photoScanProgress.beginGlobalPhotoScan(db); + scanStarted = true; + + const loggedUser = req.user.name; + + if (loggedUser === 'Admin') { + await scanPhotosUser('Common', db); + for (const u of userdb.users) { + await scanPhotosUser(u.name, db); + } + return res.send({ + status: 'Scansione completata', + user: 'Admin', + scope: 'tutti gli utenti + Common' + }); + } + + await scanPhotosUser(loggedUser, db); + return res.send({ + status: 'Scansione completata', + user: loggedUser, + scope: 'utente corrente' + }); + } catch (err) { + return res.status(500).json({ + error: 'Errore durante lo scan', + details: err.message + }); + } finally { + if (scanStarted) photoScanProgress.finishGlobalPhotoScan(); + } +}); + +module.exports = router; diff --git a/routes/scanActivities.js b/routes/scanActivities.js new file mode 100644 index 0000000..28be88f --- /dev/null +++ b/routes/scanActivities.js @@ -0,0 +1,54 @@ +// routes/scanActivities.js +const express = require('express'); +const router = express.Router(); + +const scanActivitiesUser = require('../api_v1/scanner/scanActivitiesUser'); +const activityScanProgress = require('../api_v1/scanner/activityScanProgress'); +const db = require('../db/knex'); +const fs = require('fs'); + +const userdb = JSON.parse(fs.readFileSync('./api_v1/users.json', 'utf-8')); + +router.get('/', async (req, res) => { + let scanStarted = false; + + try { + await activityScanProgress.beginGlobalActivityScan(db); + scanStarted = true; + + const loggedUser = req.user.name; + + if (loggedUser === 'Admin') { + await scanActivitiesUser('Common', db); + + for (const u of userdb.users) { + await scanActivitiesUser(u.name, db); + } + + return res.send({ + status: 'Scansione attività completata', + user: 'Admin', + scope: 'tutti gli utenti + Common' + }); + } + + await scanActivitiesUser(loggedUser, db); + + return res.send({ + status: 'Scansione attività completata', + user: loggedUser, + scope: 'utente corrente' + }); + } catch (err) { + res.status(500).json({ + error: 'Errore durante lo scan attività', + details: err.message + }); + } finally { + if (scanStarted) { + activityScanProgress.finishGlobalActivityScan(); + } + } +}); + +module.exports = router; diff --git a/routes/scanStatus.js b/routes/scanStatus.js new file mode 100644 index 0000000..a9f867a --- /dev/null +++ b/routes/scanStatus.js @@ -0,0 +1,90 @@ +// routes/scanStatus.js +const express = require('express'); +const router = express.Router(); +const db = require('../db/knex'); +const photoIngestQueue = require('../api_v1/scanner/photoIngestQueue'); +const photoScanProgress = require('../api_v1/scanner/photoScanProgress'); +const activityScanProgress = require('../api_v1/scanner/activityScanProgress'); +const activityIngestQueue = require('../api_v1/scanner/activityIngestQueue'); + + +router.get('/photos', async (req, res) => { + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + + try { + const status = await photoScanProgress.getGlobalPhotoScanStatus(db); + res.json(status); + } catch (err) { + console.error('[scanStatus/photos] Errore:', err.message); + res.status(500).json({ + ok: false, + error: 'Impossibile leggere lo stato della coda foto' + }); + } +}); + +router.get('/activities', async (req, res) => { + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + + try { + const status = await activityScanProgress.getGlobalActivityScanStatus(db); + res.json(status); + } catch (err) { + console.error('[scanStatus/activities] Errore:', err.message); + res.status(500).json({ + ok: false, + error: 'Impossibile leggere lo stato della coda attività' + }); + } +}); + + +// Conteggio dei job ancora in attesa o in elaborazione. +// Questa route legge le code senza modificarle e non interferisce con i worker. +router.get('/worker-queues', async (req, res) => { + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + + try { + const activityIngestQueue = require('../api_v1/scanner/activityIngestQueue'); + const [faceCounts, geocodeCounts, photoCounts, activityCounts] = await Promise.all([ + db('face_scan_jobs') + .select('status') + .count({ count: '*' }) + .groupBy('status'), + db('geocode_jobs') + .select('status') + .count({ count: '*' }) + .groupBy('status'), + photoIngestQueue.getPhotoIngestQueueCounts(db), + activityIngestQueue.getActivityIngestQueueCounts(db) + ]); + + const countStatus = (rows, statuses) => + rows.reduce((total, row) => + statuses.includes(row.status) ? total + Number(row.count || 0) : total, + 0); + + res.json({ + ok: true, + photos: Number(photoCounts.pending || 0) + + Number(photoCounts.processing || 0), + photosErrors: Number(photoCounts.error || 0), + activities: Number(activityCounts.pending || 0) + + Number(activityCounts.processing || 0), + activitiesErrors: Number(activityCounts.error || 0), + faces: countStatus(faceCounts, ['pending', 'processing']), + facesErrors: countStatus(faceCounts, ['error']), + locations: countStatus(geocodeCounts, ['pending', 'processing']), + locationsErrors: countStatus(geocodeCounts, ['error']) + }); + } catch (err) { + console.error('[worker-queues] Errore conteggio code:', err.message); + res.status(500).json({ ok: false, error: 'Impossibile leggere le code' }); + } +}); + +module.exports = router; diff --git a/routes/share.js b/routes/share.js new file mode 100644 index 0000000..e32b8a1 --- /dev/null +++ b/routes/share.js @@ -0,0 +1,1900 @@ +// routes/share.js + +const express = require("express"); +const router = express.Router(); + +const fs = require("fs"); +const path = require("path"); +const sharp = require("sharp"); +const mime = require("mime-types"); + +const db = require("../db/knex"); +const config = require("../api_v1/config"); +const { WEB_ROOT } = require("../api_v1/config"); + +const { jwtMiddleware } = require("../middleware/jwt"); +const { + getShareRecord, + createShareRecord, + shareTokens, +} = require("../utils/shareTokens"); + +const { resolveFilePathForEntryId } = require("../utils/resolveFilePath"); + +const { parseRange } = require("../utils/range"); + +/* + * ============================================================ + * CONFIGURAZIONE SHARE E CAST + * ============================================================ + */ + +/* + * Durata predefinita dei token Cast. + * + * 21600 secondi = 6 ore. + * + * Puoi cambiare facilmente questo valore, per esempio: + * + * 3600 = 1 ora + * 10800 = 3 ore + * 21600 = 6 ore + * 43200 = 12 ore + * 86400 = 24 ore + */ +const DEFAULT_SHARE_TTL_SECONDS = 21600; + +/* + * Limite massimo accettabile specificato dal client. + * + * Serve a evitare che un client possa generare accidentalmente + * un token con durata eccessiva. + * + * 604800 secondi = 7 giorni. + */ +const MAX_SHARE_TTL_SECONDS = 604800; + +/* + * Frequenza dei log durante lo streaming. + * + * 5000 millisecondi = un log ogni 5 secondi. + */ +const STREAM_PROGRESS_INTERVAL_MS = 5000; + +/* + * Dopo quanti millisecondi senza nuovi dati deve essere + * generato un avviso. + */ +const STREAM_IDLE_WARNING_MS = 5000; + +/* + * Se true, nei log viene incluso il percorso completo del file. + * + * Puoi impostarlo a false se non vuoi mostrare i percorsi + * completi nei log di produzione. + */ +const LOG_FULL_FILE_PATH = true; + +/* + * Sequenza interna per assegnare un ID univoco a ogni richiesta. + */ +const CAST_MAX_WIDTH = 1920; +const CAST_MAX_HEIGHT = 1080; +const CAST_JPEG_QUALITY = 85; + +/* + * Impedisce di generare due volte la stessa variante quando + * arrivano richieste concorrenti per la medesima fotografia. + */ +const pendingCastVariants = new Map(); + +let shareRequestSequence = 0; + +/* + * ============================================================ + * FUNZIONI DI SUPPORTO PER I LOG + * ============================================================ + */ + +function nextShareRequestId() { + shareRequestSequence += 1; + return shareRequestSequence; +} + +function calculateMbps(bytes, milliseconds) { + if ( + !Number.isFinite(bytes) || + !Number.isFinite(milliseconds) || + milliseconds <= 0 + ) { + return 0; + } + + return (bytes * 8 * 1000) / (milliseconds * 1000000); +} + +function formatMbps(value) { + if (!Number.isFinite(value)) { + return "0.00"; + } + + return value.toFixed(2); +} + +function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes < 0) { + return "0 B"; + } + + if (bytes < 1024) { + return `${bytes} B`; + } + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(2)} KB`; + } + + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + } + + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +/* + * Non registriamo il token completo nei log, perché equivale + * temporaneamente a una credenziale pubblica per accedere al file. + */ +function maskToken(token) { + if (!token || typeof token !== "string") { + return "-"; + } + + if (token.length <= 12) { + return `${token.substring(0, 4)}...`; + } + + return `${token.substring(0, 6)}...${token.substring(token.length - 4)}`; +} + +function safeFilePathForLog(filePath) { + if (!filePath) { + return null; + } + + if (LOG_FULL_FILE_PATH) { + return filePath; + } + + return path.basename(filePath); +} + +function getRecordExpirationLogData(rec) { + if (!rec) { + return { + expiresAt: null, + remainingTtlSeconds: null, + }; + } + + const rawExpiresAt = rec.expiresAt || rec.expireAt || rec.expires || null; + + if (!rawExpiresAt) { + return { + expiresAt: null, + remainingTtlSeconds: null, + }; + } + + let expiresAtMs; + + if (rawExpiresAt instanceof Date) { + expiresAtMs = rawExpiresAt.getTime(); + } else if (typeof rawExpiresAt === "number") { + /* + * Se il valore è piccolo, viene interpretato come timestamp Unix + * espresso in secondi. Altrimenti viene interpretato in millisecondi. + */ + expiresAtMs = + rawExpiresAt < 100000000000 ? rawExpiresAt * 1000 : rawExpiresAt; + } else { + expiresAtMs = new Date(rawExpiresAt).getTime(); + } + + if (!Number.isFinite(expiresAtMs)) { + return { + expiresAt: String(rawExpiresAt), + remainingTtlSeconds: null, + }; + } + + return { + expiresAt: new Date(expiresAtMs).toISOString(), + remainingTtlSeconds: Math.floor((expiresAtMs - Date.now()) / 1000), + }; +} + +/* + * ============================================================ + * PUBLIC BASE URL + * + * Per Chromecast deve essere un URL raggiungibile direttamente + * dal dispositivo Cast. + * ============================================================ + */ + +function getPublicBaseUrl(req) { + if (config.BASE_URL) { + return config.BASE_URL.replace(/\/+$/, ""); + } + + const forwardedProto = req.headers["x-forwarded-proto"]; + const forwardedHost = req.headers["x-forwarded-host"]; + + /* + * Alcuni proxy possono inviare più valori separati da virgola. + * Prendiamo il primo valore. + */ + const proto = forwardedProto + ? String(forwardedProto).split(",")[0].trim() + : req.protocol || "https"; + + const host = forwardedHost + ? String(forwardedHost).split(",")[0].trim() + : req.headers.host; + + return `${proto}://${host}`; +} + +/* + * ============================================================ + * RISOLUZIONE VARIANTI FOTO + * ============================================================ + */ + +function deriveCastPublicPathFromOriginal(originalPublicPath) { + if (!originalPublicPath || typeof originalPublicPath !== "string") { + return null; + } + + const normalized = originalPublicPath.replace(/\\/g, "/"); + + /* + * Esempio: + * + * /photos/Fabio/original/cartella/IMG_0244.JPG + * + * diventa: + * + * /cast/Fabio/photos/cartella/IMG_0244_cast.jpg + * + * Vengono mantenute anche eventuali sottocartelle: + * + * /photos/Fabio/original/2026/Vacanze/IMG_0244.JPG + * + * diventa: + * + * /cast/Fabio/photos/2026/Vacanze/IMG_0244_cast.jpg + */ + + const match = normalized.match( + /^\/photos\/([^/]+)\/original\/(.+)$/ + ); + + if (!match) { + return null; + } + + const user = match[1]; + const relativePath = match[2]; + + const dir = path.posix.dirname(relativePath); + const ext = path.posix.extname(relativePath); + const base = path.posix.basename(relativePath, ext); + + return path.posix.join( + "/cast", + user, + "photos", + dir === "." ? "" : dir, + `${base}_cast.jpg` + ); +} + +function resolvePublicPathToFile(publicPath) { + if (!publicPath || typeof publicPath !== "string") { + return null; + } + + const clean = publicPath.replace(/^\/+/, ""); + + return path.join(__dirname, "..", WEB_ROOT, clean); +} + +function getCastVariantPaths(photoRow) { + if (!photoRow || !photoRow.path) { + return null; + } + + const publicPath = + deriveCastPublicPathFromOriginal(photoRow.path); + + if (!publicPath) { + return null; + } + + const filePath = + resolvePublicPathToFile(publicPath); + + if (!filePath) { + return null; + } + + return { + publicPath, + filePath, + }; +} + +async function fileExistsAndIsNotEmpty(filePath) { + if (!filePath) { + return false; + } + + try { + const stat = await fs.promises.stat(filePath); + + return stat.isFile() && stat.size > 0; + } catch { + return false; + } +} + +async function isCastVariantCurrent({ + originalPath, + castPath, +}) { + try { + const [originalStat, castStat] = + await Promise.all([ + fs.promises.stat(originalPath), + fs.promises.stat(castPath), + ]); + + return ( + originalStat.isFile() && + originalStat.size > 0 && + castStat.isFile() && + castStat.size > 0 && + castStat.mtimeMs >= originalStat.mtimeMs + ); + } catch { + return false; + } +} + +async function generateCastVariant({ + entryId, + originalPath, + outputPath, +}) { + const current = await isCastVariantCurrent({ + originalPath, + castPath: outputPath, + }); + + if (current) { + const stat = await fs.promises.stat(outputPath); + + console.log( + "[share/cast-variant] cache-hit", + "entryId=", + entryId, + "path=", + safeFilePathForLog(outputPath), + "bytes=", + stat.size, + ); + + return { + filePath: outputPath, + generated: false, + width: null, + height: null, + }; + } + + const pending = + pendingCastVariants.get(outputPath); + + if (pending) { + console.log( + "[share/cast-variant] reuse-pending", + "entryId=", + entryId, + "path=", + safeFilePathForLog(outputPath), + ); + + return pending; + } + + const request = (async () => { + const generationStartedAt = Date.now(); + + await fs.promises.mkdir( + path.dirname(outputPath), + { + recursive: true, + }, + ); + + /* + * Il temporaneo mantiene estensione JPEG per rendere + * esplicito a sharp il formato di output. + */ + const temporaryPath = + `${outputPath}.${process.pid}.${Date.now()}.part.jpg`; + + try { + const sourceStat = + await fs.promises.stat(originalPath); + + if ( + !sourceStat.isFile() || + sourceStat.size <= 0 + ) { + throw new Error( + `Originale Cast non valido: ${originalPath}`, + ); + } + + console.log( + "[share/cast-variant] generate-start", + "entryId=", + entryId, + "originalPath=", + safeFilePathForLog(originalPath), + "outputPath=", + safeFilePathForLog(outputPath), + "originalBytes=", + sourceStat.size, + ); + + const result = await sharp( + originalPath, + { + failOn: "none", + }, + ) + /* + * Applica l'orientamento EXIF. + */ + .rotate() + .resize({ + width: CAST_MAX_WIDTH, + height: CAST_MAX_HEIGHT, + fit: "inside", + withoutEnlargement: true, + }) + /* + * Rimuove la trasparenza prima della conversione JPEG. + */ + .flatten({ + background: { + r: 0, + g: 0, + b: 0, + }, + }) + .jpeg({ + quality: CAST_JPEG_QUALITY, + progressive: true, + chromaSubsampling: "4:2:0", + optimiseCoding: true, + }) + .toFile(temporaryPath); + + /* + * Pubblicazione atomica. + * Il file definitivo appare soltanto quando è completo. + */ + await fs.promises.rename( + temporaryPath, + outputPath, + ); + + const outputStat = + await fs.promises.stat(outputPath); + + console.log( + "[share/cast-variant] generate-end", + "entryId=", + entryId, + "result=success", + "width=", + result.width, + "height=", + result.height, + "bytes=", + outputStat.size, + "ms=", + Date.now() - generationStartedAt, + ); + + return { + filePath: outputPath, + generated: true, + width: result.width, + height: result.height, + }; + } catch (error) { + await fs.promises.rm( + temporaryPath, + { + force: true, + }, + ); + + console.error( + "[share/cast-variant] generate-end", + "entryId=", + entryId, + "result=error", + "ms=", + Date.now() - generationStartedAt, + "error=", + error.message, + ); + + throw error; + } + })(); + + pendingCastVariants.set( + outputPath, + request, + ); + + try { + return await request; + } finally { + if ( + pendingCastVariants.get(outputPath) === + request + ) { + pendingCastVariants.delete(outputPath); + } + } +} + +function pickSharePathFromPhotoRow(row, variant) { + if (!row) { + return null; + } + + const requested = String(variant || "original").toLowerCase(); + + if (requested === "cast") { + return deriveCastPublicPathFromOriginal(row.path); + } + + if (requested === "avg") { + return row.thub2 || row.path; + } + + if (requested === "min") { + return row.thub1 || row.thub2 || row.path; + } + + return row.path; +} + +/* + * Calcola il TTL effettivo richiesto dal client. + * + * Se manca o non è valido viene usato DEFAULT_SHARE_TTL_SECONDS. + * Se supera MAX_SHARE_TTL_SECONDS viene applicato il limite massimo. + */ +function resolveShareTtlSeconds(value) { + let ttlSeconds = parseInt(value || String(DEFAULT_SHARE_TTL_SECONDS), 10); + + if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) { + ttlSeconds = DEFAULT_SHARE_TTL_SECONDS; + } + + if (ttlSeconds > MAX_SHARE_TTL_SECONDS) { + ttlSeconds = MAX_SHARE_TTL_SECONDS; + } + + return ttlSeconds; +} + +/* + * ============================================================ + * SHARE CREATE ENDPOINT + * + * POST /share/create + * ============================================================ + */ + +router.post("/create", jwtMiddleware, async (req, res) => { + const requestStartedAt = Date.now(); + + try { + const entryId = req.body.entryId || req.body.remoteId; + + if (!entryId || String(entryId).trim() === "") { + return res.status(400).json({ + status: 400, + message: "missing entryId", + }); + } + + const cleanEntryId = String(entryId).trim(); + + /* + * La variante predefinita rimane "cast" per le fotografie. + * + * Per i video Flutter dovrebbe inviare: + * + * variant: original + */ + const variant = String( + req.body.variant || req.body.shareVariant || "cast", + ).toLowerCase(); + + const ttlSeconds = resolveShareTtlSeconds(req.body.ttlSeconds); + + /* + * Per Chromecast deve normalmente essere false. + * + * Chromecast può inviare più richieste HEAD, GET e Range + * per lo stesso contenuto. + */ + const oneTime = req.body.oneTime === true; + + console.log( + "[share/create]", + "entryId=", + cleanEntryId, + "variant=", + variant, + "ttlSeconds=", + ttlSeconds, + "oneTime=", + oneTime, + "user=", + req.user?.name || "-", + ); + + if (oneTime) { + console.warn( + "[share/create]", + "WARNING oneTime=true is not recommended for Chromecast", + "entryId=", + cleanEntryId, + ); + } + + /* + * Prova a leggere la riga DB, così possiamo scegliere + * original, avg, min oppure cast. + */ + let photoRow = null; + + try { + photoRow = await db("photos").where({ id: cleanEntryId }).first(); + } catch (err) { + console.warn("[share/create] photo row lookup error:", err); + } + + console.log("[share/create] photoRow paths", { + path: photoRow?.path, + thub1: photoRow?.thub1, + thub2: photoRow?.thub2, + }); + + let filePath = null; + + /* + * Se la riga DB esiste, prova a scegliere il percorso + * richiesto tramite variant. + */ + if (photoRow) { + const selectedPublicPath = pickSharePathFromPhotoRow(photoRow, variant); + + const selectedFilePath = resolvePublicPathToFile(selectedPublicPath); + + if (selectedFilePath && fs.existsSync(selectedFilePath)) { + filePath = selectedFilePath; + + console.log( + "[share/create] selected variant", + "variant=", + variant, + "entryId=", + cleanEntryId, + "publicPath=", + selectedPublicPath, + "filePath=", + safeFilePathForLog(filePath), + ); + } else { + console.warn( + "[share/create] selected variant missing, fallback to original", + "variant=", + variant, + "publicPath=", + selectedPublicPath, + "filePath=", + safeFilePathForLog(selectedFilePath), + ); + } + } + + /* + * Fallback originale. + * + * Viene utilizzato anche per i video quando la variante + * cast non esiste. + */ + if (!filePath) { + filePath = await resolveFilePathForEntryId(cleanEntryId); + } + + /* + * Fallback per entryId già corrispondente a un percorso. + */ + if (!filePath) { + const candidate = path.isAbsolute(cleanEntryId) + ? cleanEntryId + : path.join(__dirname, "..", WEB_ROOT, cleanEntryId); + + if (fs.existsSync(candidate)) { + filePath = candidate; + } + } + + if (!filePath) { + console.warn("[share/create] file not found", "entryId=", cleanEntryId); + + return res.status(404).json({ + status: 404, + message: "file not found", + entryId: cleanEntryId, + }); + } + + if (!fs.existsSync(filePath)) { + console.warn( + "[share/create] file does not exist", + "entryId=", + cleanEntryId, + "filePath=", + safeFilePathForLog(filePath), + ); + + return res.status(404).json({ + status: 404, + message: "file does not exist", + entryId: cleanEntryId, + filePath, + }); + } + + /* + * Permission check: + * + * Admin può condividere tutto. + * Gli altri utenti possono condividere solo i propri file. + */ + try { + const row = photoRow; + + if (row) { + if (req.user?.name !== "Admin" && row.user !== req.user?.name) { + console.warn( + "[share/create] forbidden", + "entryId=", + cleanEntryId, + "owner=", + row.user, + "requestUser=", + req.user?.name, + ); + + return res.status(403).json({ + status: 403, + message: "forbidden", + }); + } + } else if (req.user?.name !== "Admin") { + /* + * Se non abbiamo una riga DB, consenti percorsi + * arbitrari solo ad Admin. + */ + console.warn( + "[share/create] forbidden path without DB row", + "entryId=", + cleanEntryId, + "requestUser=", + req.user?.name, + ); + + return res.status(403).json({ + status: 403, + message: "forbidden", + }); + } + } catch (err) { + console.warn("[share/create] permission check error:", err); + + return res.status(500).json({ + status: 500, + message: "permission check failed", + }); + } + + const stat = await fs.promises.stat(filePath); + + /* + * /share/create non genera la variante Cast. + * + * Registra invece nel token: + * - percorso originale; + * - percorso previsto della variante Cast; + * - tipo di variante richiesta. + * + * La variante viene generata soltanto da + * POST /share/prepare-cast. + */ + const originalPath = + photoRow?.path + ? resolvePublicPathToFile(photoRow.path) + : filePath; + + const castPaths = + variant === "cast" && photoRow + ? getCastVariantPaths(photoRow) + : null; + + const shareFilePath = + originalPath || filePath; + + const { token, expiresAt } = createShareRecord( + cleanEntryId, + shareFilePath, + ttlSeconds, + oneTime, + req.user?.name || null, + { + variant, + originalPath: shareFilePath, + castPath: castPaths?.filePath || null, + }, + ); + + filePath = shareFilePath; + + const baseUrl = getPublicBaseUrl(req); + + const url = `${baseUrl}/share/` + encodeURIComponent(token); + + console.log( + "[share/create] completed", + "entryId=", + cleanEntryId, + "variant=", + variant, + "token=", + maskToken(token), + "ttlSeconds=", + ttlSeconds, + "expiresAt=", + expiresAt, + "oneTime=", + oneTime, + "fileName=", + path.basename(filePath), + "size=", + stat.size, + "sizeFormatted=", + formatBytes(stat.size), + "mime=", + mime.lookup(filePath) || "application/octet-stream", + "ms=", + Date.now() - requestStartedAt, + ); + + return res.json({ + url, + expiresAt, + ttlSeconds, + oneTime, + variant, + fileName: path.basename(filePath), + size: stat.size, + contentType: + mime.lookup(filePath) || + "application/octet-stream", + generated: false, + prepared: false, + }); + } catch (err) { + console.error("[share/create] error:", err); + + return res.status(500).json({ + status: 500, + message: err.message, + }); + } +}); + +/* + * ============================================================ + * PREPARAZIONE BATCH VARIANTI CAST + * + * POST /share/prepare-cast + * + * Genera soltanto le foto remote inviate dalla finestra + * Flutter -5/+4. + * ============================================================ + */ + +router.post( + "/prepare-cast", + jwtMiddleware, + async (req, res) => { + const requestStartedAt = Date.now(); + + const rawEntryIds = + Array.isArray(req.body.entryIds) + ? req.body.entryIds + : []; + + const entryIds = [ + ...new Set( + rawEntryIds + .map((value) => String(value).trim()) + .filter((value) => value.length > 0), + ), + ]; + + if (entryIds.length === 0) { + return res.status(400).json({ + status: 400, + message: "missing entryIds", + }); + } + + /* + * Protezione da richieste batch eccessive. + * La finestra prevista è -5/+4, quindi normalmente + * contiene al massimo 10 elementi. + */ + if (entryIds.length > 20) { + return res.status(400).json({ + status: 400, + message: "too many entryIds", + maximum: 20, + }); + } + + console.log( + "[share/prepare-cast] start", + "requested=", + entryIds.length, + "user=", + req.user?.name || "-", + ); + + /* + * Le elaborazioni sono lanciate insieme. + * generateCastVariant deduplica già richieste concorrenti + * relative allo stesso file. + */ + const results = await Promise.all( + entryIds.map(async (entryId) => { + try { + const photoRow = + await db("photos") + .where({ id: entryId }) + .first(); + + if (!photoRow) { + return { + entryId, + status: "not-found", + }; + } + + if ( + req.user?.name !== "Admin" && + photoRow.user !== req.user?.name + ) { + return { + entryId, + status: "forbidden", + }; + } + + if (!photoRow.path) { + return { + entryId, + status: "missing-original-path", + }; + } + + const originalPath = + resolvePublicPathToFile( + photoRow.path, + ); + + const castPaths = + getCastVariantPaths(photoRow); + + const originalMime = + originalPath + ? mime.lookup(originalPath) + : null; + + const originalReady = + originalPath + ? await fileExistsAndIsNotEmpty( + originalPath, + ) + : false; + + const isImage = + typeof originalMime === "string" && + originalMime.startsWith("image/"); + + if ( + !originalPath || + !originalReady || + !castPaths || + !isImage + ) { + return { + entryId, + status: "skipped", + originalReady, + originalMime: + originalMime || null, + }; + } + + const prepared = + await generateCastVariant({ + entryId, + originalPath, + outputPath: + castPaths.filePath, + }); + + return { + entryId, + status: + prepared.generated + ? "generated" + : "cached", + generated: + prepared.generated, + width: + prepared.width, + height: + prepared.height, + fileName: + path.basename( + prepared.filePath, + ), + }; + } catch (error) { + console.error( + "[share/prepare-cast] item-error", + "entryId=", + entryId, + "error=", + error.message, + ); + + return { + entryId, + status: "error", + message: error.message, + }; + } + }), + ); + + const summary = + results.reduce( + (value, item) => { + value[item.status] = + (value[item.status] || 0) + 1; + + return value; + }, + {}, + ); + + console.log( + "[share/prepare-cast] completed", + "requested=", + entryIds.length, + "summary=", + summary, + "ms=", + Date.now() - requestStartedAt, + ); + + return res.json({ + status: 200, + requested: + entryIds.length, + summary, + results, + elapsedMs: + Date.now() - requestStartedAt, + }); + }, +); + +/* + * ============================================================ + * SHARE BASE64 ENDPOINT + * + * POST /share/base64 + * ============================================================ + */ + +router.post("/base64", jwtMiddleware, async (req, res) => { + try { + const entryId = req.body.entryId || req.body.remoteId; + + if (!entryId || String(entryId).trim() === "") { + return res.status(400).json({ + status: 400, + message: "missing entryId", + }); + } + + const cleanEntryId = String(entryId).trim(); + + let filePath = await resolveFilePathForEntryId(cleanEntryId); + + if (!filePath) { + const candidate = path.isAbsolute(cleanEntryId) + ? cleanEntryId + : path.join(__dirname, "..", WEB_ROOT, cleanEntryId); + + if (fs.existsSync(candidate)) { + filePath = candidate; + } + } + + if (!filePath || !fs.existsSync(filePath)) { + return res.status(404).json({ + status: 404, + message: "file not found", + entryId: cleanEntryId, + }); + } + + try { + const row = await db("photos").where({ id: cleanEntryId }).first(); + + if (row) { + if (req.user?.name !== "Admin" && row.user !== req.user?.name) { + return res.status(403).json({ + status: 403, + message: "forbidden", + }); + } + } else if (req.user?.name !== "Admin") { + return res.status(403).json({ + status: 403, + message: "forbidden", + }); + } + } catch (err) { + console.warn("[share/base64] permission check error:", err); + + return res.status(500).json({ + status: 500, + message: "permission check failed", + }); + } + + /* + * Questo endpoint legge il file interamente in memoria. + * Non deve essere usato per il casting di video lunghi. + */ + const buffer = await fs.promises.readFile(filePath); + + const mimeType = mime.lookup(filePath) || "application/octet-stream"; + + const base64 = buffer.toString("base64"); + + console.log( + "[share/base64] completed", + "entryId=", + cleanEntryId, + "fileName=", + path.basename(filePath), + "bytes=", + buffer.length, + "mime=", + mimeType, + ); + + return res.json({ + status: 200, + mime: mimeType, + base64, + filePath, + }); + } catch (err) { + console.error("[share/base64] error:", err); + + return res.status(500).json({ + status: 500, + message: err.message, + }); + } +}); + +/* + * ============================================================ + * SHARE PUBLIC ENDPOINT + * + * GET /share/:token + * HEAD /share/:token + * + * Supporta: + * + * 200 OK + * 206 Partial Content + * 404 Not Found + * 405 Method Not Allowed + * 416 Range Not Satisfiable + * ============================================================ + */ + +async function serveShareToken(req, res) { + const requestId = nextShareRequestId(); + const requestStartedAt = Date.now(); + + const token = req.params.token; + const maskedToken = maskToken(token); + const rangeHeader = req.headers.range || null; + + let stream = null; + let progressTimer = null; + + let expectedBytes = 0; + let streamedBytes = 0; + + let previousStreamedBytes = 0; + let previousProgressAt = requestStartedAt; + let lastDataAt = requestStartedAt; + + let streamEnded = false; + let streamClosed = false; + let responseFinished = false; + let responseClosed = false; + let requestAborted = false; + let cleanupCompleted = false; + + let responseStart = null; + let responseEnd = null; + let responseTotal = null; + let responseStatus = null; + + function log(event, details = {}) { + console.log( + `[SHARE STREAM][${new Date().toISOString()}]`, + `requestId=${requestId}`, + `event=${event}`, + { + token: maskedToken, + ...details, + }, + ); + } + + function stopProgressTimer() { + if (progressTimer !== null) { + clearInterval(progressTimer); + progressTimer = null; + } + } + + function cleanup(reason) { + if (cleanupCompleted) { + return; + } + + cleanupCompleted = true; + stopProgressTimer(); + + const elapsedMs = Date.now() - requestStartedAt; + + log("cleanup", { + reason, + method: req.method, + status: responseStatus || res.statusCode, + range: rangeHeader || "-", + responseRange: + responseStart !== null + ? `${responseStart}-${responseEnd}/${responseTotal}` + : null, + streamedBytes, + streamedBytesFormatted: formatBytes(streamedBytes), + expectedBytes, + expectedBytesFormatted: formatBytes(expectedBytes), + byteCountMatches: + expectedBytes > 0 ? streamedBytes === expectedBytes : null, + elapsedMs, + averageMbps: formatMbps(calculateMbps(streamedBytes, elapsedMs)), + streamEnded, + streamClosed, + responseFinished, + responseClosed, + requestAborted, + headersSent: res.headersSent, + writableEnded: res.writableEnded, + responseDestroyed: res.destroyed, + }); + } + + log("request-start", { + method: req.method, + url: req.originalUrl, + range: rangeHeader || "-", + ip: req.ip, + protocol: req.protocol, + userAgent: req.headers["user-agent"] || "-", + connection: req.headers.connection || "-", + forwardedFor: req.headers["x-forwarded-for"] || "-", + }); + + try { + /* + * Recupera il token dalla memoria. + * + * Se non esiste può essere: + * + * 1. token errato; + * 2. token scaduto; + * 3. token oneTime già utilizzato; + * 4. server Node riavviato. + */ + const rec = getShareRecord(token); + + if (!rec) { + responseStatus = 404; + + log("token-not-found-or-expired", { + range: rangeHeader || "-", + elapsedMs: Date.now() - requestStartedAt, + }); + + return res.status(404).send("Not found"); + } + + const expirationData = getRecordExpirationLogData(rec); + + log("token-found", { + entryId: rec.entryId || null, + owner: rec.owner || null, + oneTime: !!rec.oneTime, + hasFilePath: !!rec.filePath, + variant: rec.variant || "original", + hasOriginalPath: !!rec.originalPath, + hasCastPath: !!rec.castPath, + expiresAt: expirationData.expiresAt, + remainingTtlSeconds: expirationData.remainingTtlSeconds, + }); + + let filePath = rec.filePath; + + /* + * Lo stesso URL share può passare automaticamente + * dall'originale alla variante Cast. + * + * Se /share/prepare-cast ha già generato il file, + * viene servito il JPEG Cast. Altrimenti resta disponibile + * l'originale, senza restituire 404. + */ + if ( + rec.variant === "cast" && + rec.castPath + ) { + const castReady = + await fileExistsAndIsNotEmpty( + rec.castPath, + ); + + if (castReady) { + filePath = rec.castPath; + + log("cast-representation-selected", { + entryId: rec.entryId || null, + representation: "cast", + filePath: + safeFilePathForLog(filePath), + }); + } else { + filePath = + rec.originalPath || + rec.filePath; + + log("cast-representation-fallback", { + entryId: rec.entryId || null, + representation: "original", + castPath: + safeFilePathForLog( + rec.castPath, + ), + filePath: + safeFilePathForLog(filePath), + }); + } + } + + if (!filePath && rec.entryId) { + const resolveStartedAt = Date.now(); + + filePath = await resolveFilePathForEntryId(rec.entryId); + + log("file-path-resolved", { + entryId: rec.entryId, + found: !!filePath, + resolveMs: Date.now() - resolveStartedAt, + filePath: safeFilePathForLog(filePath), + }); + } + + if (!filePath || !fs.existsSync(filePath)) { + responseStatus = 404; + + log("file-not-found", { + entryId: rec.entryId || null, + filePath: safeFilePathForLog(filePath), + }); + + return res.status(404).send("File not found"); + } + + const statStartedAt = Date.now(); + const stat = await fs.promises.stat(filePath); + const statMs = Date.now() - statStartedAt; + + if (!stat.isFile()) { + responseStatus = 404; + + log("path-is-not-file", { + filePath: safeFilePathForLog(filePath), + }); + + return res.status(404).send("File not found"); + } + + const total = stat.size; + + let contentType = "application/octet-stream"; + + try { + contentType = mime.lookup(filePath) || "application/octet-stream"; + } catch (err) { + log("mime-lookup-error", { + error: err.message, + }); + } + + res.setHeader("Accept-Ranges", "bytes"); + + res.setHeader("Content-Type", contentType); + + /* + * Per immagini consentiamo una cache breve. + * + * Per video e audio usiamo no-cache senza no-store. + * In questo modo il receiver può conservare dati già ricevuti, + * ma deve rivalidare quando necessario. + */ + if (contentType.startsWith("image/")) { + res.setHeader("Cache-Control", "private, max-age=300"); + } else { + res.setHeader("Cache-Control", "private, no-cache, must-revalidate"); + } + + log("file-resolved", { + entryId: rec.entryId || null, + fileName: path.basename(filePath), + filePath: safeFilePathForLog(filePath), + total, + totalFormatted: formatBytes(total), + contentType, + statMs, + lastModified: stat.mtime.toISOString(), + cacheControl: res.getHeader("Cache-Control"), + remainingTtlSeconds: expirationData.remainingTtlSeconds, + }); + + /* + * Gestione file vuoto. + */ + if (total === 0) { + responseStatus = 200; + expectedBytes = 0; + + res.status(200); + res.setHeader("Content-Length", "0"); + + log("empty-file-response", { + status: 200, + }); + + return res.end(); + } + + /* + * HEAD + * + * Restituisce gli stessi header essenziali del GET, + * ma non apre il file e non invia il body. + */ + if (req.method === "HEAD") { + responseStatus = 200; + expectedBytes = total; + + res.status(200); + res.setHeader("Content-Length", total); + + log("response-head", { + status: 200, + total, + totalFormatted: formatBytes(total), + contentType, + elapsedMs: Date.now() - requestStartedAt, + }); + + return res.end(); + } + + if (req.method !== "GET") { + responseStatus = 405; + + res.setHeader("Allow", "GET, HEAD"); + + res.setHeader("Content-Length", "0"); + + log("method-not-allowed", { + method: req.method, + }); + + return res.status(405).end(); + } + + /* + * Calcolo intervallo da inviare. + */ + let start; + let end; + + if (!rangeHeader) { + responseStatus = 200; + start = 0; + end = total - 1; + expectedBytes = total; + + res.status(200); + + res.setHeader("Content-Length", expectedBytes); + } else { + const range = parseRange(rangeHeader, total); + + if (!range) { + responseStatus = 416; + + res.setHeader("Content-Range", `bytes */${total}`); + + res.setHeader("Content-Length", "0"); + + log("range-not-satisfiable", { + status: 416, + requestedRange: rangeHeader, + total, + }); + + return res.status(416).end(); + } + + start = range.start; + end = range.end; + + expectedBytes = end - start + 1; + + responseStatus = 206; + + res.status(206); + + res.setHeader("Content-Range", `bytes ${start}-${end}/${total}`); + + res.setHeader("Content-Length", expectedBytes); + } + + responseStart = start; + responseEnd = end; + responseTotal = total; + + log("stream-start", { + status: responseStatus, + requestedRange: rangeHeader || "-", + responseRange: `${start}-${end}/${total}`, + expectedBytes, + expectedBytesFormatted: formatBytes(expectedBytes), + fileName: path.basename(filePath), + filePath: safeFilePathForLog(filePath), + contentType, + remainingTtlSeconds: expirationData.remainingTtlSeconds, + preparationMs: Date.now() - requestStartedAt, + }); + + /* + * Crea lo stream solo per la sezione richiesta. + * + * In fs.createReadStream: + * + * start è incluso. + * end è incluso. + */ + stream = fs.createReadStream(filePath, { + start, + end, + }); + + /* + * Log periodico della velocità. + */ + progressTimer = setInterval(() => { + const now = Date.now(); + + const intervalMs = now - previousProgressAt; + + const intervalBytes = streamedBytes - previousStreamedBytes; + + const idleMs = now - lastDataAt; + + const currentMbps = calculateMbps(intervalBytes, intervalMs); + + const averageMbps = calculateMbps(streamedBytes, now - requestStartedAt); + + const completionPercent = + expectedBytes > 0 ? (streamedBytes * 100) / expectedBytes : 0; + + log("stream-progress", { + status: responseStatus, + responseRange: `${start}-${end}/${total}`, + streamedBytes, + streamedBytesFormatted: formatBytes(streamedBytes), + expectedBytes, + expectedBytesFormatted: formatBytes(expectedBytes), + intervalBytes, + intervalBytesFormatted: formatBytes(intervalBytes), + intervalMs, + idleMs, + currentMbps: formatMbps(currentMbps), + averageMbps: formatMbps(averageMbps), + completionPercent: completionPercent.toFixed(1), + writableLength: res.writableLength, + writableEnded: res.writableEnded, + responseDestroyed: res.destroyed, + streamPaused: stream?.isPaused() || false, + }); + + if ( + idleMs >= STREAM_IDLE_WARNING_MS && + streamedBytes < expectedBytes && + !streamEnded && + !responseFinished + ) { + log("stream-no-progress-warning", { + idleMs, + warningThresholdMs: STREAM_IDLE_WARNING_MS, + streamedBytes, + expectedBytes, + remainingBytes: expectedBytes - streamedBytes, + writableLength: res.writableLength, + streamPaused: stream?.isPaused() || false, + responseDestroyed: res.destroyed, + }); + } + + previousStreamedBytes = streamedBytes; + + previousProgressAt = now; + }, STREAM_PROGRESS_INTERVAL_MS); + + /* + * I dati vengono conteggiati mentre attraversano lo stream. + * + * Con stream.pipe(res), la backpressure del socket controlla + * il flusso del file. + */ + stream.on("data", (chunk) => { + streamedBytes += chunk.length; + lastDataAt = Date.now(); + }); + + stream.on("end", () => { + streamEnded = true; + + const elapsedMs = Date.now() - requestStartedAt; + + log("file-stream-end", { + streamedBytes, + streamedBytesFormatted: formatBytes(streamedBytes), + expectedBytes, + expectedBytesFormatted: formatBytes(expectedBytes), + byteCountMatches: streamedBytes === expectedBytes, + elapsedMs, + averageMbps: formatMbps(calculateMbps(streamedBytes, elapsedMs)), + }); + }); + + stream.on("close", () => { + streamClosed = true; + + log("file-stream-close", { + streamedBytes, + expectedBytes, + byteCountMatches: streamedBytes === expectedBytes, + streamEnded, + responseFinished, + requestAborted, + }); + }); + + stream.on("error", (err) => { + stopProgressTimer(); + + log("file-stream-error", { + error: err.message, + code: err.code || null, + stack: err.stack, + streamedBytes, + expectedBytes, + streamEnded, + responseFinished, + requestAborted, + }); + + if (!res.headersSent) { + responseStatus = 500; + + res.status(500).send("Stream error"); + } else if (!res.destroyed) { + res.destroy(err); + } + }); + + req.on("aborted", () => { + requestAborted = true; + + log("request-aborted", { + streamedBytes, + expectedBytes, + streamEnded, + responseFinished, + writableEnded: res.writableEnded, + }); + + if (stream && !stream.destroyed) { + stream.destroy(); + } + + cleanup("request-aborted"); + }); + + res.on("finish", () => { + responseFinished = true; + + const elapsedMs = Date.now() - requestStartedAt; + + log("response-finish", { + status: responseStatus || res.statusCode, + streamedBytes, + streamedBytesFormatted: formatBytes(streamedBytes), + expectedBytes, + expectedBytesFormatted: formatBytes(expectedBytes), + byteCountMatches: streamedBytes === expectedBytes, + elapsedMs, + averageMbps: formatMbps(calculateMbps(streamedBytes, elapsedMs)), + }); + + cleanup("response-finish"); + }); + + res.on("close", () => { + responseClosed = true; + + log("response-close", { + status: responseStatus || res.statusCode, + streamedBytes, + expectedBytes, + byteCountMatches: streamedBytes === expectedBytes, + streamEnded, + streamClosed, + responseFinished, + requestAborted, + writableEnded: res.writableEnded, + }); + + if (!responseFinished && stream && !stream.destroyed) { + stream.destroy(); + } + + cleanup( + responseFinished + ? "response-close-after-finish" + : "response-close-before-finish", + ); + }); + + stream.pipe(res); + + /* + * Token one-time. + * + * Non è consigliato per Chromecast perché lo stesso file + * può ricevere più richieste HEAD, GET e Range. + * + * Se oneTime=true, il token viene eliminato dopo l'avvio + * del primo GET. + */ + if (rec.oneTime) { + log("one-time-token-delete", { + warning: "oneTime token is not recommended for Cast", + }); + + if (shareTokens && typeof shareTokens.delete === "function") { + shareTokens.delete(token); + } + } + } catch (err) { + stopProgressTimer(); + + log("handler-error", { + error: err.message, + code: err.code || null, + stack: err.stack, + streamedBytes, + expectedBytes, + headersSent: res.headersSent, + }); + + if (!res.headersSent) { + responseStatus = 500; + + res.status(500).send("Server error"); + } else if (!res.destroyed) { + res.destroy(err); + } + + cleanup("handler-error"); + } +} + +/* + * Express può utilizzare il gestore GET anche per HEAD. + * + * La registrazione esplicita mantiene comunque chiaro + * il comportamento previsto. + */ +router.route("/:token").get(serveShareToken).head(serveShareToken); + +module.exports = router; diff --git a/routes/users.js b/routes/users.js new file mode 100644 index 0000000..f0757b9 --- /dev/null +++ b/routes/users.js @@ -0,0 +1,39 @@ +const express = require('express'); +const router = express.Router(); + +const fs = require('fs'); +const path = require('path'); + +const usersPath = path.resolve(__dirname, '..', 'api_v1', 'users.json'); + +router.get('/', async (req, res) => { + try { + const loggedUser = req.user?.name; + + if (loggedUser !== 'Admin') { + return res.status(403).json({ + ok: false, + error: 'Forbidden' + }); + } + + const userdb = JSON.parse(fs.readFileSync(usersPath, 'utf-8')); + + const users = (userdb.users || []) + .filter(u => u && u.name) + .map(u => ({ + name: u.name + })); + + res.json(users); + } catch (err) { + console.error('❌ Errore GET /users:', err); + + res.status(500).json({ + ok: false, + error: err.message + }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js new file mode 100644 index 0000000..154dfe1 --- /dev/null +++ b/server.js @@ -0,0 +1,162 @@ +require('dotenv').config(); + +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const config = require('./api_v1/config'); +const { + startWsOutboxConsumer +} = require('./ws-server/ws-outbox-consumer'); + +const { jwtMiddleware } = require('./middleware/jwt'); +const staticMiddleware = require('./middleware/static'); + +const { + createWaitForCastSegment +} = require('./middleware/waitForCastSegment'); +// removed old hls middleware +// const hlsMiddleware = require('./middleware/hls'); + +const authRouter = require('./routes/auth'); +const configRouter = require('./routes/config'); +const scanRouter = require('./routes/scan'); +const autoScanRouter = require('./routes/autoScan'); +const shareRouter = require('./routes/share'); +const photosRouter = require('./routes/photos'); +const activitiesRouter = require('./routes/activities'); +const scanActivitiesRouter = require('./routes/scanActivities'); +const scanStatusRouter = require('./routes/scanStatus'); +const usersRouter = require('./routes/users'); +const faceReviewRouter = require('./routes/faceReview'); + +// nuovo: router HLS che sta in routes/hls.js +const hlsRouter = require('./routes/hls'); + +const server = express(); + +// CORS per i flussi video HLS pubblici +server.use(['/hls', '/cast'], (req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); + res.setHeader( + 'Access-Control-Allow-Headers', + 'Origin, Accept, Content-Type, Range, Authorization' + ); + res.setHeader( + 'Access-Control-Expose-Headers', + 'Content-Length, Content-Range, Accept-Ranges, Content-Type' + ); + + if (req.method === 'OPTIONS') { + return res.sendStatus(204); + } + + next(); +}); + + +/* + * I manifest VOD anticipati possono fare riferimento a segmenti + * che FFmpeg non ha ancora prodotto. + * + * Per le sole richieste /cast/.../segment_XXXXX.ts, il server + * attende la comparsa del file invece di rispondere subito 404. + * + * Deve essere montato prima di express.static. + */ +const castRoot = path.resolve( + __dirname, + 'public', + 'cast' +); + +server.use( + '/cast', + createWaitForCastSegment({ + castRoot, + + // Attesa massima per un seek molto avanti. + timeoutMs: + Number( + process.env.CAST_SEGMENT_WAIT_TIMEOUT_MS || + 300000 + ), + + // Controlla la comparsa del file ogni 100 ms. + pollMs: + Number( + process.env.CAST_SEGMENT_WAIT_POLL_MS || + 100 + ), + + // Evita di servire un segmento ancora in scrittura. + stabilityMs: + Number( + process.env.CAST_SEGMENT_STABILITY_MS || + 50 + ) + }) +); + + +// Statici pubblici (mantieni il tuo middleware statico) +server.use(staticMiddleware); + +// Body parser (deve essere prima delle route che leggono JSON) +server.use(express.json()); +server.use(express.urlencoded({ extended: true })); + +// Base directory per gli output HLS (puoi sovrascriverla con env HLS_BASE) +const HLS_BASE = process.env.HLS_BASE || path.join(process.cwd(), 'hls_output'); +fs.mkdirSync(HLS_BASE, { recursive: true }); + +// Serve i file HLS generati (master.m3u8 e .ts) pubblicamente su /hls +server.use('/hls', express.static(HLS_BASE)); + +// Login pubblico +server.get('/login', (req, res) => { + res.sendFile(path.join(__dirname, 'public/login.html')); +}); + +// Rotte pubbliche +server.use('/auth', authRouter); +server.use('/config', configRouter); +//server.use('/share', shareRouter); + +// JWT per tutto il resto (mantieni la tua regex) +server.use( + /^(?!\/auth)(?!\/share)(?!\/login)(?!\/login.html)(?!\/css)(?!\/js)(?!\/img)(?!\/fonts)(?!\/favicon).*$/, + jwtMiddleware +); + +// Monta il router HLS dopo il jwtMiddleware in modo che le API siano protette +// GET statici /hls/ sono già serviti pubblicamente dalla express.static sopra + +server.use('/hls', hlsRouter); + +// Rotte protette +server.use('/share', shareRouter); +server.use('/users', usersRouter); +server.use('/face-review', faceReviewRouter); +server.use('/photos', photosRouter); +server.use('/activities', activitiesRouter); +server.use('/scan', scanRouter); +server.use('/scanActivities', scanActivitiesRouter); +server.use('/api_v1/auto_scan', autoScanRouter); +server.use('/scanStatus', scanStatusRouter); + +// Home +server.get('/', (req, res) => { + res.sendFile(path.resolve('public/index.html')); +}); + + +server.listen(config.PORT, () => { + console.log(`Server running on port ${config.PORT}`); + + // Solo il processo principale consuma la outbox e possiede + // le connessioni WebSocket. + startWsOutboxConsumer({ + pollMs: Number(process.env.WS_OUTBOX_POLL_MS || 250) + }); +}); diff --git a/test_fn/abilita_upload_multiplo.py b/test_fn/abilita_upload_multiplo.py new file mode 100644 index 0000000..a59a203 --- /dev/null +++ b/test_fn/abilita_upload_multiplo.py @@ -0,0 +1,129 @@ +from pathlib import Path +import shutil +import sys + +p = Path("public/js/photoUpload.js") +s = p.read_text(encoding="utf-8") + +# Individua la funzione completa tramite i marcatori reali. +start_picker = s.find(" function chooseFile() {") +end_picker = s.find("\n function createDialog()", start_picker) + +if start_picker < 0 or end_picker < 0: + sys.exit("ERRORE: funzione chooseFile non trovata. File invariato.") + +new_picker = ''' function chooseFile() { + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.multiple = true; + input.style.display = "none"; + document.body.appendChild(input); + + let finished = false; + + function finish(files) { + if (finished) return; + finished = true; + input.remove(); + window.removeEventListener("focus", onFocus); + resolve(files); + } + + function onFocus() { + setTimeout(() => { + if (!input.files?.length) finish([]); + }, 500); + } + + input.addEventListener("change", () => { + finish(Array.from(input.files || [])); + }, { once: true }); + + window.addEventListener("focus", onFocus, { once: true }); + input.click(); + }); + } +''' + +# Trova la sezione dell'upload singolo fino al commento successivo. +start_upload = s.find(' setStatus("Seleziona la foto...");') +end_upload = s.find(" // Mantiene invariato il comportamento precedente della galleria.", start_upload) + +if start_upload < 0 or end_upload < 0: + sys.exit("ERRORE: blocco upload non trovato. File invariato.") + +old_upload = s[start_upload:end_upload] + +if "const file = await chooseFile();" not in old_upload: + sys.exit("ERRORE: upload già modificato o struttura inattesa. File invariato.") + +new_upload = ''' setStatus("Seleziona una o più foto..."); + const files = await chooseFile(); + + if (!files.length) { + setStatus("Nessuna foto selezionata."); + return; + } + + const invalidFile = files.find( + file => file.type && !file.type.startsWith("image/") + ); + + if (invalidFile) { + throw new Error(`Il file "${invalidFile.name}" non è un'immagine.`); + } + + const formData = new FormData(); + formData.append("source", destination.source); + formData.append("folder", destination.folder); + + for (const file of files) { + formData.append("photo", file, file.name); + } + + setStatus(`Caricamento di ${files.length} foto...`); + + const result = await api("/photos/upload", { + method: "POST", + body: formData, + }); + + const uploadedCount = Array.isArray(result.files) + ? result.files.length + : files.length; + + setStatus( + `${uploadedCount} foto caricate. Attendo l'indicizzazione...` + ); + +''' + +# Verifica il numero di occorrenze prima di toccare il file. +if s.count(" function chooseFile() {") != 1: + sys.exit("ERRORE: occorrenze inattese di chooseFile. File invariato.") + +backup = p.with_name("photoUpload.js.bak-multiplo-v2") +if backup.exists(): + sys.exit(f"ERRORE: {backup} esiste già. File invariato.") + +updated = s[:start_picker] + new_picker + s[end_picker:] +# Ricalcola gli indici perché la funzione precedente è cambiata. +start_upload_new = updated.find(' setStatus("Seleziona la foto...");') +end_upload_new = updated.find( + " // Mantiene invariato il comportamento precedente della galleria.", + start_upload_new +) + +if start_upload_new < 0 or end_upload_new < 0: + sys.exit("ERRORE: impossibile ricollocare il blocco upload. File invariato.") + +updated = updated[:start_upload_new] + new_upload + updated[end_upload_new:] + +# Backup e scrittura soltanto dopo tutte le verifiche. +shutil.copy2(p, backup) +p.write_text(updated, encoding="utf-8") + +print("Modifica completata.") +print(f"Backup creato: {backup}") diff --git a/test_fn/callback_server.py b/test_fn/callback_server.py new file mode 100644 index 0000000..316127c --- /dev/null +++ b/test_fn/callback_server.py @@ -0,0 +1,25 @@ +# callback_server.py +from http.server import HTTPServer, BaseHTTPRequestHandler +import json + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(length) if length else b'' + try: + data = json.loads(body.decode('utf-8')) if body else None + except Exception: + data = body.decode('utf-8', errors='replace') + print('POST', self.path) + print('Headers:', dict(self.headers)) + print('Body:', data) + # rispondi 200 OK + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps({'ok': True}).encode('utf-8')) + +if __name__ == '__main__': + server = HTTPServer(('192.168.1.4', 8765), Handler) + print('Listening on http://192.168.1.4:8765') + server.serve_forever() diff --git a/test_fn/cast.sh b/test_fn/cast.sh new file mode 100644 index 0000000..f6535f6 --- /dev/null +++ b/test_fn/cast.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +SERVER="https://prova.patachina.it" +EMAIL="fabio@gmail.com" +PASS="master66" +ENTRY_ID="751d8a15766bf2686bc87e60b33b4a8c38befe454428e6a5a82b00f26026c0e0" + +TOKEN=$(curl -s -X POST "$SERVER/auth/login" -H "Content-Type: application/json" -d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" | jq -r .token) +echo "JWT: $TOKEN" + +RESP=$(curl -s -X POST "$SERVER/share/create" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "{\"entryId\":\"$ENTRY_ID\",\"ttlSeconds\":600,\"oneTime\":false}") +echo "create response: $RESP" +URL=$(echo "$RESP" | jq -r .url) +echo "share url: $URL" + +echo "download full" +curl -v -L "$URL" -o /tmp/test.jpg + +echo "test range" +curl -v -L -H "Range: bytes=0-1023" "$URL" -o /tmp/part.bin diff --git a/test_fn/cast1.sh b/test_fn/cast1.sh new file mode 100644 index 0000000..cc24c8e --- /dev/null +++ b/test_fn/cast1.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +# CONFIGURA QUI +SERVER="https://prova.patachina.it" +EMAIL="fabio@gmail.com" +PASS="master66" +ENTRY_ID="751d8a15766bf2686bc87e60b33b4a8c38befe454428e6a5a82b00f26026c0e0" +TTL=600 + +# FILE temporanei +OUT_FULL="/tmp/share_test_full.jpg" +OUT_PART="/tmp/share_test_part.bin" +RESP_JSON="/tmp/share_create_resp.json" + +echo "1) Login..." +TOKEN=$(curl -s -X POST "$SERVER/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" | jq -r .token) + +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "Login fallito. Controlla credenziali." + exit 1 +fi +echo "JWT ottenuto." + +echo "2) Creo share per entryId $ENTRY_ID (ttl=${TTL}s)..." +curl -s -X POST "$SERVER/share/create" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"entryId\":\"$ENTRY_ID\",\"ttlSeconds\":$TTL,\"oneTime\":false}" \ + -o "$RESP_JSON" + +cat "$RESP_JSON" | jq . + +URL=$(jq -r .url < "$RESP_JSON") +EXPIRES_AT=$(jq -r .expiresAt < "$RESP_JSON") + +if [ -z "$URL" ] || [ "$URL" = "null" ]; then + echo "Creazione share fallita. Output:" + cat "$RESP_JSON" + exit 1 +fi + +echo "Share creato: $URL" +echo "Scadenza: $EXPIRES_AT" + +echo "3) Scarico file completo (seguo redirect e TLS)..." +curl -v -L "$URL" -o "$OUT_FULL" +echo "Salvato in $OUT_FULL (size: $(stat -c%s "$OUT_FULL") bytes)" + +echo "4) Test Range bytes=0-1023..." +curl -v -L -H "Range: bytes=0-1023" "$URL" -o "$OUT_PART" +echo "Salvato in $OUT_PART (size: $(stat -c%s "$OUT_PART") bytes)" + +echo "5) Intestazioni HEAD (senza body)" +curl -I -L "$URL" || true + +echo "Fatto. Controlla i file in /tmp e i log della tua app se necessario." diff --git a/test_fn/find-esm.js b/test_fn/find-esm.js new file mode 100644 index 0000000..14ad418 --- /dev/null +++ b/test_fn/find-esm.js @@ -0,0 +1,40 @@ +const fs = require("fs"); +const path = require("path"); + +function scan(dir) { + const items = fs.readdirSync(dir, { withFileTypes: true }); + + for (const item of items) { + const full = path.join(dir, item.name); + + if (item.isDirectory()) { + scan(full); + continue; + } + + // 1) File .mjs → ESM sicuro + if (full.endsWith(".mjs")) { + console.log("⚠️ FILE .mjs (ESM):", full); + continue; + } + + // 2) File .js → controlliamo se contiene import/export + if (full.endsWith(".js")) { + const content = fs.readFileSync(full, "utf8"); + + if (/^\s*import\s/m.test(content)) { + console.log("⚠️ IMPORT trovato:", full); + } + if (/^\s*export\s/m.test(content)) { + console.log("⚠️ EXPORT trovato:", full); + } + if (/import\.meta\.url/.test(content)) { + console.log("⚠️ import.meta.url trovato:", full); + } + } + } +} + +console.log("🔍 Scansione in corso..."); +scan(process.cwd()); +console.log("✔️ Scansione completata."); diff --git a/test_fn/fix_hls_cast.py b/test_fn/fix_hls_cast.py new file mode 100644 index 0000000..fc1125d --- /dev/null +++ b/test_fn/fix_hls_cast.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 + +from pathlib import Path +from datetime import datetime +import re +import shutil +import sys + +ROOT = Path("/home/nvme/dockerdata/prove/s152js") +MANAGER = ROOT / "helpers/hlsManager.js" +ROUTE = ROOT / "routes/hls.js" + + +def replace_once(text, pattern, replacement, label, flags=0): + new_text, count = re.subn( + pattern, replacement, text, count=1, flags=flags + ) + if count != 1: + raise RuntimeError( + f"{label}: attesa 1 sostituzione, trovate {count}. " + "File non modificati." + ) + print(f"[OK] {label}") + return new_text + + +def main(): + if not MANAGER.is_file() or not ROUTE.is_file(): + sys.exit(f"ERRORE: file non trovati sotto {ROOT}") + + manager = MANAGER.read_text(encoding="utf-8") + route = ROUTE.read_text(encoding="utf-8") + + # 1. Stato esplicito del job. + manager = replace_once( + manager, + r"notified:\s*false,", + "notified: false,\n" + " ready: false,\n" + " status: 'processing',", + "Stato iniziale del job", + ) + + # 2. Il poller dichiara il job pronto solo alla soglia richiesta. + manager = replace_once( + manager, + r"if\s*\(\s*tsCount\s*>=\s*job\.segmentsNeeded\s*\)" + r"\s*\{\s*job\.notified\s*=\s*true;", + "if (tsCount >= job.segmentsNeeded) {\n" + " job.ready = true;\n" + " job.status = 'ready';\n" + " job.notified = true;", + "Soglia dei segmenti HLS", + ) + + # 3. Registra esplicitamente il timeout. + timeout_start = manager.find("// Timeout") + exit_start = manager.find("// FFmpeg exit", timeout_start) + + if timeout_start < 0 or exit_start < 0: + raise RuntimeError("Blocco timeout non trovato. Nessun file scritto.") + + timeout_block = manager[timeout_start:exit_start] + timeout_block = replace_once( + timeout_block, + r"job\.notified\s*=\s*true;", + "job.notified = true;\n job.status = 'timeout';", + "Stato timeout", + ) + manager = ( + manager[:timeout_start] + + timeout_block + + manager[exit_start:] + ) + + # 4. All'uscita di FFmpeg, non confondere fine conversione + # con buffer sufficiente per iniziare il casting. + manager = replace_once( + manager, + r"let status;\s*" + r"if\s*\(code === 0 && masterExists\)\s*\{\s*" + r"status = 'finished';\s*" + r"\}\s*else\s*\{\s*" + r"status = 'error';\s*" + r"\}\s*" + r"job\.notified\s*=\s*true;", + "const hasMinimumBuffer =\n" + " code === 0 &&\n" + " masterExists &&\n" + " tsCount >= job.segmentsNeeded;\n" + "\n" + " if (hasMinimumBuffer) {\n" + " job.ready = true;\n" + " }\n" + "\n" + " job.status = job.ready\n" + " ? 'ready'\n" + " : (code === 0 && masterExists\n" + " ? 'finished_insufficient_segments'\n" + " : 'error');\n" + "\n" + " status = job.status;\n" + " job.notified = true;", + "Controllo finale all'uscita di FFmpeg", + ) + + # 5. Conserva il job per due minuti dopo l'uscita di FFmpeg. + manager = replace_once( + manager, + r"finally\s*\{\s*jobs\.delete\(\s*jobId\s*\);\s*\}", + "finally {\n" + " job.finishedAt = Date.now();\n" + " setTimeout(() => {\n" + " jobs.delete(jobId);\n" + " }, 2 * 60 * 1000).unref();\n" + " }", + "Conservazione temporanea del job", + ) + + # 6. Aggiunge il conteggio locale dei segmenti .ts alla route. + route = replace_once( + route, + r"const router = express\.Router\(\);", + "const router = express.Router();\n" + "\n" + "function countTsSegments(outDir) {\n" + " try {\n" + " return fs.readdirSync(outDir).filter(\n" + " name => name.toLowerCase().endsWith('.ts')\n" + " ).length;\n" + " } catch (err) {\n" + " return 0;\n" + " }\n" + "}", + "Conteggio segmenti nella route", + ) + + # 7. L'endpoint URL richiede almeno segmentsNeeded segmenti. + route = replace_once( + route, + r"if\s*\(\s*" + r"!job\.notified\s*\|\|\s*" + r"!fs\.existsSync\(masterPath\)\s*" + r"\)\s*\{\s*" + r"return res\.status\(404\)\.json\(\{\s*" + r"error:\s*'Playlist not yet ready'\s*" + r"\}\);\s*" + r"\}", + "const tsCount = countTsSegments(job.outDir);\n" + "\n" + " if (\n" + " !job.ready ||\n" + " !fs.existsSync(masterPath) ||\n" + " tsCount < job.segmentsNeeded\n" + " ) {\n" + " if (job.exitCode !== null) {\n" + " return res.status(409).json({\n" + " error: 'HLS finished before minimum buffer was ready',\n" + " status: job.status,\n" + " ready_segments: tsCount,\n" + " required_segments: job.segmentsNeeded\n" + " });\n" + " }\n" + "\n" + " return res.status(404).json({\n" + " error: 'Playlist not yet ready',\n" + " ready_segments: tsCount,\n" + " required_segments: job.segmentsNeeded\n" + " });\n" + " }", + "Controllo della soglia nell'endpoint URL", + flags=re.S, + ) + + # Tutte le sostituzioni sono state verificate prima di scrivere. + # Crea backup e poi salva entrambi i file. + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_manager = MANAGER.with_name( + f"hlsManager.js.bak_{stamp}" + ) + backup_route = ROUTE.with_name( + f"hls.js.bak_{stamp}" + ) + + shutil.copy2(MANAGER, backup_manager) + shutil.copy2(ROUTE, backup_route) + + MANAGER.write_text(manager, encoding="utf-8") + ROUTE.write_text(route, encoding="utf-8") + + print("\nModifiche applicate.") + print(f"Backup: {backup_manager}") + print(f"Backup: {backup_route}") + print("\nVerifica ora la sintassi JavaScript con:") + print("node --check helpers/hlsManager.js") + print("node --check routes/hls.js") + + +if __name__ == "__main__": + main() diff --git a/test_fn/hls_cors_server.py b/test_fn/hls_cors_server.py new file mode 100644 index 0000000..ed68d6a --- /dev/null +++ b/test_fn/hls_cors_server.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +import os +import argparse +import sys + +def parse_args(): + p = argparse.ArgumentParser(description="Semplice server HTTP per HLS con CORS") + p.add_argument("root", nargs="?", default="hls_ts_test", + help="Directory da servire (default: hls_ts_test)") + p.add_argument("-p", "--port", type=int, default=9999, + help="Porta su cui ascoltare (default: 9999)") + return p.parse_args() + +class HlsHandler(SimpleHTTPRequestHandler): + extensions_map = { + **SimpleHTTPRequestHandler.extensions_map, + ".m3u8": "application/vnd.apple.mpegurl", + ".ts": "video/mp2t", + } + + def end_headers(self): + # CORS e cache control + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Expose-Headers", "Content-Length, Content-Type, Accept-Ranges") + self.send_header("Cache-Control", "no-cache") + super().end_headers() + + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Range") + self.end_headers() + +def main(): + args = parse_args() + root = os.path.abspath(args.root) + port = args.port + + if not os.path.isdir(root): + print(f"Directory non trovata: {root}", file=sys.stderr) + sys.exit(1) + + os.chdir(root) + + server = ThreadingHTTPServer(("0.0.0.0", port), HlsHandler) + print(f"Serving {root} on http://0.0.0.0:{port}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nServer interrotto dall'utente") + finally: + server.server_close() + +if __name__ == "__main__": + main() diff --git a/test_fn/hls_cors_server2.py b/test_fn/hls_cors_server2.py new file mode 100644 index 0000000..b677e9a --- /dev/null +++ b/test_fn/hls_cors_server2.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 + +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +import os +import argparse +import sys + + +def parse_args(): + p = argparse.ArgumentParser( + description="Semplice server HTTP per HLS con CORS" + ) + p.add_argument( + "root", + nargs="?", + default="hls_ts_test", + help="Directory da servire (default: hls_ts_test)" + ) + p.add_argument( + "-p", + "--port", + type=int, + default=9999, + help="Porta su cui ascoltare (default: 9999)" + ) + return p.parse_args() + + +class HlsHandler(SimpleHTTPRequestHandler): + extensions_map = { + **SimpleHTTPRequestHandler.extensions_map, + ".m3u8": "application/vnd.apple.mpegurl", + ".ts": "video/mp2t", + } + + def end_headers(self): + # CORS e cache control + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header( + "Access-Control-Expose-Headers", + "Content-Length, Content-Type, Accept-Ranges" + ) + self.send_header("Cache-Control", "no-cache") + super().end_headers() + + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header( + "Access-Control-Allow-Methods", + "GET, HEAD, OPTIONS" + ) + self.send_header("Access-Control-Allow-Headers", "Range") + self.end_headers() + + +class QuietThreadingHTTPServer(ThreadingHTTPServer): + def handle_error(self, request, client_address): + exc_type, exc, tb = sys.exc_info() + + # Client che chiude la connessione durante lo streaming. + # Situazione normale per browser, Chromecast e player HLS. + if isinstance(exc, (BrokenPipeError, ConnectionResetError)): + return + + super().handle_error(request, client_address) + + +def main(): + args = parse_args() + root = os.path.abspath(args.root) + port = args.port + + if not os.path.isdir(root): + print(f"Directory non trovata: {root}", file=sys.stderr) + sys.exit(1) + + os.chdir(root) + + server = QuietThreadingHTTPServer( + ("0.0.0.0", port), + HlsHandler + ) + + print( + f"Serving {root} on http://0.0.0.0:{port}", + flush=True + ) + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nServer interrotto dall'utente") + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/test_fn/hls_cors_server_wait.py b/test_fn/hls_cors_server_wait.py new file mode 100755 index 0000000..7572c2e --- /dev/null +++ b/test_fn/hls_cors_server_wait.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 + +from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler +from pathlib import Path +from urllib.parse import unquote, urlparse +import argparse +import mimetypes +import os +import re +import time + + +SEGMENT_PATTERN = re.compile( + r"^segment_[0-9]{5}[.]ts$" +) + + +class HlsWaitingHandler(SimpleHTTPRequestHandler): + server_version = "HlsWaitingServer/1.0" + + def end_headers(self): + self.send_header( + "Access-Control-Allow-Origin", + "*", + ) + self.send_header( + "Access-Control-Allow-Methods", + "GET, HEAD, OPTIONS", + ) + self.send_header( + "Access-Control-Allow-Headers", + "Range, Content-Type, Origin, Accept", + ) + self.send_header( + "Access-Control-Expose-Headers", + "Content-Length, Content-Range, Accept-Ranges", + ) + self.send_header( + "Accept-Ranges", + "bytes", + ) + + if self.path.lower().endswith(".m3u8"): + self.send_header( + "Cache-Control", + "no-cache, no-store, must-revalidate", + ) + self.send_header( + "Pragma", + "no-cache", + ) + self.send_header( + "Expires", + "0", + ) + else: + self.send_header( + "Cache-Control", + "public, max-age=31536000, immutable", + ) + + super().end_headers() + + def do_OPTIONS(self): + self.send_response(204) + self.end_headers() + + def is_future_segment_request(self): + parsed = urlparse(self.path) + filename = Path( + unquote(parsed.path) + ).name + + return bool( + SEGMENT_PATTERN.fullmatch(filename) + ) + + def requested_file_path(self): + translated = Path( + self.translate_path( + urlparse(self.path).path + ) + ) + + try: + return translated.resolve() + except OSError: + return translated.absolute() + + def file_is_inside_root(self, file_path): + try: + root = Path( + self.directory + ).resolve() + + file_path.relative_to(root) + return True + except (OSError, ValueError): + return False + + def file_is_stable(self, file_path): + """ + Un file TS può esistere mentre FFmpeg lo sta ancora scrivendo. + + Lo consideriamo pronto quando: + - esiste; + - ha dimensione maggiore di zero; + - la dimensione resta invariata per due controlli consecutivi. + """ + try: + first_size = file_path.stat().st_size + + if first_size <= 0: + return False + + time.sleep( + self.server.stability_interval + ) + + second_size = file_path.stat().st_size + + return ( + second_size > 0 and + second_size == first_size + ) + except OSError: + return False + + def conversion_still_running(self, directory): + """ + Il marker viene creato dal server quando avviato e può essere + rimosso esternamente alla fine della conversione. + + Se non viene usato alcun marker, attendiamo fino al timeout. + """ + marker = directory / ".transcoding" + + if not marker.exists(): + return True + + return marker.is_file() + + def wait_for_segment(self): + if not self.is_future_segment_request(): + return True + + file_path = self.requested_file_path() + + if not self.file_is_inside_root(file_path): + self.send_error( + 403, + "Forbidden", + ) + return False + + if self.file_is_stable(file_path): + return True + + started_at = time.monotonic() + deadline = ( + started_at + + self.server.segment_wait_timeout + ) + last_log_at = 0.0 + + self.log_message( + "WAIT segment=%s timeout=%.1fs", + file_path.name, + self.server.segment_wait_timeout, + ) + + while time.monotonic() < deadline: + if self.file_is_stable(file_path): + elapsed = ( + time.monotonic() - + started_at + ) + + self.log_message( + "READY segment=%s waited=%.3fs size=%d", + file_path.name, + elapsed, + file_path.stat().st_size, + ) + + return True + + now = time.monotonic() + + if now - last_log_at >= 2.0: + elapsed = now - started_at + + self.log_message( + "STILL WAITING segment=%s elapsed=%.1fs", + file_path.name, + elapsed, + ) + + last_log_at = now + + time.sleep( + self.server.poll_interval + ) + + self.log_message( + "TIMEOUT segment=%s waited=%.1fs", + file_path.name, + self.server.segment_wait_timeout, + ) + + self.send_error( + 503, + "Segment not ready", + ) + + return False + + def do_GET(self): + if not self.wait_for_segment(): + return + + try: + super().do_GET() + except (BrokenPipeError, ConnectionResetError): + self.log_message( + "CLIENT DISCONNECTED path=%s", + self.path, + ) + + def do_HEAD(self): + if not self.wait_for_segment(): + return + + try: + super().do_HEAD() + except (BrokenPipeError, ConnectionResetError): + self.log_message( + "CLIENT DISCONNECTED path=%s", + self.path, + ) + + def guess_type(self, path): + lower = path.lower() + + if lower.endswith(".m3u8"): + return "application/vnd.apple.mpegurl" + + if lower.endswith(".ts"): + return "video/mp2t" + + return ( + mimetypes.guess_type(path)[0] or + "application/octet-stream" + ) + + +def main(): + parser = argparse.ArgumentParser( + description=( + "Serve HLS e attende la produzione " + "dei segmenti TS futuri." + ) + ) + + parser.add_argument( + "directory", + help="Directory HLS da pubblicare", + ) + + parser.add_argument( + "--host", + default="0.0.0.0", + ) + + parser.add_argument( + "--port", + type=int, + default=9999, + ) + + parser.add_argument( + "--segment-timeout", + type=float, + default=120.0, + help=( + "Secondi massimi di attesa per " + "un segmento futuro" + ), + ) + + parser.add_argument( + "--poll-interval", + type=float, + default=0.10, + help="Intervallo di controllo del file", + ) + + parser.add_argument( + "--stability-interval", + type=float, + default=0.05, + help=( + "Intervallo usato per verificare che " + "FFmpeg abbia terminato la scrittura" + ), + ) + + args = parser.parse_args() + + root = Path( + args.directory + ).resolve() + + if not root.is_dir(): + raise SystemExit( + f"Directory non trovata: {root}" + ) + + handler = lambda *handler_args, **handler_kwargs: ( + HlsWaitingHandler( + *handler_args, + directory=str(root), + **handler_kwargs, + ) + ) + + server = ThreadingHTTPServer( + ( + args.host, + args.port, + ), + handler, + ) + + server.segment_wait_timeout = ( + args.segment_timeout + ) + server.poll_interval = ( + args.poll_interval + ) + server.stability_interval = ( + args.stability_interval + ) + + print( + f"Serving {root} on " + f"http://{args.host}:{args.port}" + ) + print( + "Future TS requests will wait up to " + f"{args.segment_timeout:.1f} seconds." + ) + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopping server.") + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/test_fn/modifica_upload_multiplo.py b/test_fn/modifica_upload_multiplo.py new file mode 100644 index 0000000..e6cbf58 --- /dev/null +++ b/test_fn/modifica_upload_multiplo.py @@ -0,0 +1,147 @@ +from pathlib import Path +import shutil +import re +import sys + +file = Path("public/js/photoUpload.js") + +if not file.is_file(): + sys.exit(f"File non trovato: {file}") + +text = file.read_text(encoding="utf-8") + +# Evita di applicare la modifica due volte. +if "input.multiple = true;" in text: + sys.exit("La selezione multipla risulta già abilitata. Nessuna modifica.") + +backup = file.with_name("photoUpload.js.bak-prima-upload-multiplo") +shutil.copy2(file, backup) + +# Abilita la selezione di più immagini. +old = ''' input.type = "file"; + input.accept = "image/*";''' +new = ''' input.type = "file"; + input.accept = "image/*"; + input.multiple = true;''' + +if old not in text: + sys.exit( + "Non trovo il codice atteso del selettore file. " + f"Backup disponibile in {backup}; file originale non modificato." + ) + +text = text.replace(old, new, 1) + +# Il selettore deve restituire tutti i file, non soltanto il primo. +old = ''' resolve(file);''' +new = ''' resolve(file);''' + +# Aggiorna la funzione di selezione per restituire un array. +start = text.find(" function chooseFile() {") +end = text.find("\\n function createDialog()", start) + +if start < 0 or end < 0: + sys.exit( + "Non riesco a individuare la funzione chooseFile. " + f"Backup creato in {backup}; file originale non modificato." + ) + +choose_function = ''' function chooseFile() { + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.multiple = true; + input.style.display = "none"; + document.body.appendChild(input); + + let finished = false; + + function finish(files) { + if (finished) return; + finished = true; + input.remove(); + window.removeEventListener("focus", onFocus); + resolve(files); + } + + function onFocus() { + setTimeout(() => { + if (!input.files?.length) finish([]); + }, 500); + } + + input.addEventListener("change", () => { + finish(Array.from(input.files || [])); + }, { once: true }); + + window.addEventListener("focus", onFocus, { once: true }); + input.click(); + }); + } +''' + +text = text[:start] + choose_function + text[end:] + +# Aggiorna la parte che carica i file. +old = ''' const file = await chooseFile(); + + if (!file) { + setStatus("Nessuna foto selezionata."); + return; + } + + if (file.type && !file.type.startsWith("image/")) { + throw new Error("Seleziona un file immagine."); + } + + const formData = new FormData(); + formData.append("source", destination.source); + formData.append("folder", destination.folder); + formData.append("photo", file, file.name); + + setStatus(`Caricamento di ${file.name}...`);''' + +new = ''' const files = await chooseFile(); + + if (!files.length) { + setStatus("Nessuna foto selezionata."); + return; + } + + const invalidFile = files.find( + file => file.type && !file.type.startsWith("image/") + ); + + if (invalidFile) { + throw new Error(`Il file "${invalidFile.name}" non è un'immagine.`); + } + + const formData = new FormData(); + formData.append("source", destination.source); + formData.append("folder", destination.folder); + + for (const file of files) { + formData.append("photo", file, file.name); + } + + setStatus(`Caricamento di ${files.length} foto...`);''' + +if old not in text: + sys.exit( + "Non trovo il blocco di upload atteso. " + f"Backup creato in {backup}; file originale non modificato." + ) + +text = text.replace(old, new, 1) + +# Aggiorna il messaggio finale senza cambiare il refresh. +text = text.replace( + '''`Foto caricata: ${result.files?.[0]?.fileName || file.name}. ` +''', + '''`${files.length} foto caricate. ` +''', + 1 +) + +file.write_text(text, encoding="utf-8") +print(f"Modifica completata: {file}") +print(f"Backup originale: {backup}") diff --git a/test_fn/print_function.sh b/test_fn/print_function.sh new file mode 100755 index 0000000..533d78d --- /dev/null +++ b/test_fn/print_function.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Estrae e stampa una funzione da un file. +# +# UTILIZZO: +# ./print_function.sh +# +# ESEMPIO: +# ./print_function.sh myFunction myfile +# +# La funzione viene stampata sullo standard output. + + + +if [ "$#" -ne 2 ]; then + echo "Uso: $0 " + exit 1 +fi + +FUNCTION="$1" +FILE="$2" + +awk -v func="$FUNCTION" ' +/^[[:space:]]*function[[:space:]]+/ { + if ($0 ~ "^[[:space:]]*function[[:space:]]+" func "[[:space:]]*\\{") { + found=1 + braces=0 + } +} + +found { + print + braces += gsub(/\{/, "{") + braces -= gsub(/\}/, "}") + + if (braces == 0) { + exit + } +} +' "$FILE" diff --git a/test_fn/prova_vod_anticipata.sh b/test_fn/prova_vod_anticipata.sh new file mode 100755 index 0000000..0bb6a01 --- /dev/null +++ b/test_fn/prova_vod_anticipata.sh @@ -0,0 +1,374 @@ +#!/usr/bin/env bash + +set -eu + +PROJECT="/home/nvme/dockerdata/prove/s157js" + +VIDEO="${1:-$PROJECT/public/photos/Fabio/original/2017Irlanda19-29ago/VID-20260920-WA0000.mp4}" + +OUT="${2:-/tmp/prova-wait}" + +PORT="${PORT:-9999}" +SEGMENT_TIMEOUT="${SEGMENT_TIMEOUT:-300}" + +FFMPEG="/usr/local/bin/ffmpeg" +FFPROBE="/usr/local/bin/ffprobe" + +WAIT_SERVER="$PROJECT/hls_cors_server_wait.py" + +SERVER_PID="" +FFMPEG_PID="" + + +cleanup() { + echo + echo "Arresto dei processi..." + + if [ -n "$FFMPEG_PID" ]; then + kill "$FFMPEG_PID" 2>/dev/null || true + fi + + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + fi + + wait 2>/dev/null || true +} + + +trap cleanup EXIT INT TERM + + +if [ ! -f "$VIDEO" ]; then + echo "ERRORE: video non trovato:" + echo "$VIDEO" + exit 1 +fi + + +if [ ! -f "$WAIT_SERVER" ]; then + echo "ERRORE: server con attesa non trovato:" + echo "$WAIT_SERVER" + echo + echo "Deve esistere hls_cors_server_wait.py nel progetto." + exit 1 +fi + + +echo "==================================================" +echo "PROVA VOD ANTICIPATA" +echo "==================================================" +echo "Video: $VIDEO" +echo "Output: $OUT" +echo "Porta: $PORT" +echo "Timeout: $SEGMENT_TIMEOUT secondi" +echo + + +rm -rf "$OUT" +mkdir -p "$OUT" + + +echo "Avvio server HTTP con attesa dei segmenti..." + +python3 "$WAIT_SERVER" \ + "$OUT" \ + --port "$PORT" \ + --segment-timeout "$SEGMENT_TIMEOUT" & + +SERVER_PID=$! + + +sleep 0.5 + +if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "ERRORE: il server HTTP non è partito." + exit 1 +fi + + +echo +echo "Avvio FFmpeg in modalità realtime..." +echo + +"$FFMPEG" \ + -hide_banner \ + -loglevel warning \ + -y \ + -noautorotate \ + -i "$VIDEO" \ + -map 0:v:0 \ + -map '0:a:0?' \ + -vf "scale=1280:720:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,format=yuv420p" \ + -metadata:s:v:0 rotate=0 \ + -c:v libx264 \ + -profile:v high \ + -level:v 4.1 \ + -preset ultrafast \ + -tune zerolatency \ + -b:v 5000k \ + -maxrate 6000k \ + -bufsize 12000k \ + -r 30 \ + -g 90 \ + -keyint_min 90 \ + -sc_threshold 0 \ + -c:a aac \ + -b:a 160k \ + -ac 2 \ + -ar 48000 \ + -f hls \ + -hls_time 3 \ + -hls_list_size 0 \ + -hls_playlist_type event \ + -hls_segment_type mpegts \ + -hls_flags independent_segments \ + -hls_segment_filename "$OUT/segment_%05d.ts" \ + "$OUT/internal.m3u8" & + +FFMPEG_PID=$! + + +echo "FFmpeg PID: $FFMPEG_PID" +echo +echo "Attendo la chiusura dei primi 3 segmenti..." + + +# La comparsa del segmento 3 indica che i segmenti 0, 1 e 2 +# sono già stati chiusi da FFmpeg. +while [ ! -s "$OUT/segment_00003.ts" ]; do + if ! kill -0 "$FFMPEG_PID" 2>/dev/null; then + echo "ERRORE: FFmpeg è terminato prima di produrre 3 segmenti." + wait "$FFMPEG_PID" || true + exit 1 + fi + + COUNT="$( + find "$OUT" \ + -maxdepth 1 \ + -type f \ + -name 'segment_*.ts' \ + 2>/dev/null \ + | wc -l \ + | tr -d ' ' + )" + + echo "Segmenti visibili: $COUNT" + + sleep 0.2 +done + + +echo +echo "Primi 3 segmenti completi." +echo "Creazione della playlist VOD anticipata..." + + +"$FFPROBE" \ + -v error \ + -show_entries format=duration \ + -of default=noprint_wrappers=1:nokey=1 \ + "$VIDEO" \ + > "$OUT/.duration.txt" + + +python3 - "$OUT" <<'PY' +from pathlib import Path +import math +import sys + +out = Path(sys.argv[1]).resolve() + +duration_file = out / ".duration.txt" +playlist = out / "cast.m3u8" +temporary = out / "cast.m3u8.tmp" + +duration = float( + duration_file.read_text( + encoding="utf-8" + ).strip() +) + +segment_duration = 3.0 + +total_segments = math.ceil( + duration / segment_duration +) + +last_duration = ( + duration - + segment_duration * (total_segments - 1) +) + +if last_duration <= 0: + last_duration = segment_duration + +lines = [ + "#EXTM3U", + "#EXT-X-VERSION:6", + "#EXT-X-TARGETDURATION:3", + "#EXT-X-MEDIA-SEQUENCE:0", + "#EXT-X-START:TIME-OFFSET=0,PRECISE=YES", + "#EXT-X-PLAYLIST-TYPE:VOD", + "#EXT-X-INDEPENDENT-SEGMENTS", +] + +for index in range(total_segments): + current_duration = ( + segment_duration + if index < total_segments - 1 + else last_duration + ) + + lines.append( + f"#EXTINF:{current_duration:.6f}," + ) + + lines.append( + f"segment_{index:05d}.ts" + ) + +lines.append("#EXT-X-ENDLIST") + +temporary.write_text( + "\n".join(lines) + "\n", + encoding="utf-8", +) + +temporary.replace(playlist) + +real_segments = len( + list(out.glob("segment_*.ts")) +) + +print() +print("Playlist VOD anticipata pubblicata") +print(f"File: {playlist}") +print(f"Durata: {duration:.6f} secondi") +print(f"Segmenti dichiarati: {total_segments}") +print(f"Segmenti già presenti: {real_segments}") +print(f"Ultimo EXTINF: {last_duration:.6f}") +PY + + +IP_ADDRESS="$( + hostname -I \ + | awk '{print $1}' +)" + + +echo +echo "==================================================" +echo "PLAYLIST PRONTA" +echo "==================================================" +echo +echo "URL:" +echo "http://$IP_ADDRESS:$PORT/cast.m3u8" +echo +echo "Dal Mac esegui:" +echo +echo "catt -d 192.168.1.15 stop" +echo "catt -d 192.168.1.15 cast \\" +echo " \"http://$IP_ADDRESS:$PORT/cast.m3u8\"" +echo +echo "Per provare un seek futuro:" +echo +echo "catt -d 192.168.1.15 seek 90" +echo +echo "Il server mostrerà WAIT e READY se il segmento" +echo "richiesto non è ancora disponibile." +echo "==================================================" +echo + + +# Attende il completamento della transcodifica. +set +e +wait "$FFMPEG_PID" +FFMPEG_STATUS=$? +set -e + +FFMPEG_PID="" + + +if [ "$FFMPEG_STATUS" -eq 0 ]; then + echo + echo "FFmpeg ha completato la conversione." + echo "Pubblicazione della playlist reale definitiva..." + + python3 - "$OUT" <<'PY' +from pathlib import Path +import sys + +out = Path(sys.argv[1]).resolve() + +internal = out / "internal.m3u8" +public = out / "cast.m3u8" +temporary = out / "cast.m3u8.final.tmp" + +if not internal.is_file(): + raise SystemExit( + f"ERRORE: playlist interna mancante: {internal}" + ) + +content = internal.read_text( + encoding="utf-8" +) + +if "#EXT-X-ENDLIST" not in content: + raise SystemExit( + "ERRORE: la playlist interna non contiene ENDLIST." + ) + +content = content.replace( + "#EXT-X-PLAYLIST-TYPE:EVENT", + "#EXT-X-PLAYLIST-TYPE:VOD", +) + +start_marker = ( + "#EXT-X-START:TIME-OFFSET=0,PRECISE=YES" +) + +if start_marker not in content: + lines = content.splitlines() + + version_index = next( + ( + index + for index, line in enumerate(lines) + if line.startswith("#EXT-X-VERSION:") + ), + 0, + ) + + lines.insert( + version_index + 1, + start_marker, + ) + + content = "\n".join(lines) + "\n" + +elif not content.endswith("\n"): + content += "\n" + +temporary.write_text( + content, + encoding="utf-8", +) + +temporary.replace(public) + +print(f"Playlist finale pubblicata: {public}") +PY + +else + echo + echo "ERRORE: FFmpeg è terminato con codice $FFMPEG_STATUS" +fi + + +echo +echo "Il server HTTP resta attivo." +echo "Premi Ctrl+C per terminare la prova." + + +wait "$SERVER_PID" diff --git a/test_fn/q.sh b/test_fn/q.sh new file mode 100644 index 0000000..1087240 --- /dev/null +++ b/test_fn/q.sh @@ -0,0 +1,4 @@ +inotifywait -m -r -e create,delete "./public/photos/Fabio/" | +while read path action file; do + echo "Evento: $action su $file" +done diff --git a/test_fn/scan.sh b/test_fn/scan.sh new file mode 100644 index 0000000..4bf8493 --- /dev/null +++ b/test_fn/scan.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +USERS_FILE="./api_v1/users.json" +PHOTOS_ROOT="./public/photos" +JWT_SECRET="123456789" +NODE_ENDPOINT="https://prova.patachina.it/scan" + +# 1. Legge gli utenti dal JSON +USERS=($(jq -r '.users[].name' "$USERS_FILE")) + + +WATCH_PATHS=() +for user in "${USERS[@]}"; do + if [[ "$user" == "Admin" ]]; then + WATCH_PATHS+=("$PHOTOS_ROOT/Common/original") + else + WATCH_PATHS+=("$PHOTOS_ROOT/$user/original") + fi +done + +echo "Monitoro:" +printf '%s\n' "${WATCH_PATHS[@]}" + +# 2. Funzione per generare JWT per un utente specifico +generate_jwt() { + local user="$1" + local header payload signature + + header=$(echo -n '{"alg":"HS256","typ":"JWT"}' | base64 -w0 | tr '+/' '-_' | tr -d '=') + payload=$(echo -n "{\"name\":\"$user\",\"email\":\"$user@system\",\"id\":999,\"ts\":$(date +%s)}" \ + | base64 -w0 | tr '+/' '-_' | tr -d '=') + signature=$(echo -n "$header.$payload" \ + | openssl dgst -sha256 -hmac "$JWT_SECRET" -binary \ + | base64 -w0 | tr '+/' '-_' | tr -d '=') + + echo "$header.$payload.$signature" +} + +# 3. Avvia inotifywait su tutte le directory +inotifywait -m -r -e create,modify,delete "${WATCH_PATHS[@]}" | +while read fullpath action file; do + echo "Evento: $action su $file" + + # 4. Estrae l’utente dal path + # Esempio: /photos/Fabio/originale/IMG001.jpg → Fabio + user=$(echo "$fullpath" | awk -F'/' '{print $(NF-2)}') + + echo "Utente rilevato: $user" + + JWT=$(generate_jwt "$user") + + curl -X GET "$NODE_ENDPOINT" \ + -H "Authorization: Bearer $JWT" +done diff --git a/test_fn/showfunc.sh b/test_fn/showfunc.sh new file mode 100755 index 0000000..595aca6 --- /dev/null +++ b/test_fn/showfunc.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +showfunc() { + if [ "$#" -ne 2 ]; then + echo "Uso:" + echo " showfunc FILE FUNZIONE" + echo + echo "Esempio:" + echo " showfunc public/js/map/mapboxMarkers.js updateSource" + return 1 + fi + + local FILE="$1" + local FUNC="$2" + + if [ ! -f "$FILE" ]; then + echo "ERRORE: file non trovato:" + echo " $FILE" + return 1 + fi + + python3 - "$FILE" "$FUNC" <<'PY' +import sys + +file = sys.argv[1] +func = sys.argv[2] + +with open(file, "r", encoding="utf-8") as f: + text = f.read() + +# Cerca "function nome(" +needle = "function " + func + "(" +start = text.find(needle) + +if start == -1: + print("FUNZIONE NON TROVATA:", func) + sys.exit(1) + +# Cerca la prima { dopo la dichiarazione +brace_start = text.find("{", start) + +if brace_start == -1: + print("ERRORE: parentesi graffa iniziale non trovata") + sys.exit(1) + +depth = 0 +end = None + +for i in range(brace_start, len(text)): + char = text[i] + + if char == "{": + depth += 1 + + elif char == "}": + depth -= 1 + + if depth == 0: + end = i + 1 + break + +if end is None: + print("ERRORE: parentesi graffe non bilanciate") + sys.exit(1) + +print(text[start:end]) +PY +} diff --git a/test_fn/t.js b/test_fn/t.js new file mode 100644 index 0000000..3dca872 --- /dev/null +++ b/test_fn/t.js @@ -0,0 +1,35 @@ +async function testApi() { + const token = prompt("Inserisci il token JWT:"); + + if (!token) { + console.error("Nessun token inserito"); + return; + } + + const user = prompt("Inserisci username (es. Fabio):") || "Fabio"; + + console.log("🔍 Test GET /photos?user=" + user); + + try { + const res = await fetch(`/photos?user=${encodeURIComponent(user)}`, { + headers: { + "Authorization": "Bearer " + token + } + }); + + const text = await res.text(); + console.log("📩 Risposta grezza:", text); + + try { + const json = JSON.parse(text); + console.log("📦 JSON:", json); + } catch (e) { + console.error("❌ Non è JSON valido:", e); + } + + } catch (err) { + console.error("❌ Errore fetch:", err); + } +} + +testApi(); diff --git a/test_fn/test-hls.js b/test_fn/test-hls.js new file mode 100644 index 0000000..267975c --- /dev/null +++ b/test_fn/test-hls.js @@ -0,0 +1,100 @@ +const fs = require('fs'); +const path = require('path'); +const hlsManager = require('./helpers/hlsManager'); + +const [, , inputArg, outputArg] = process.argv; + +if (!inputArg || !outputArg) { + console.error( + 'Uso: node test-hls.js "/percorso/video.mp4" "/percorso/output-hls"' + ); + process.exit(1); +} + +const inputPath = path.resolve(inputArg); +const outDir = path.resolve(outputArg); + +if (!fs.existsSync(inputPath) || !fs.statSync(inputPath).isFile()) { + console.error(`File video non trovato: ${inputPath}`); + process.exit(1); +} + +if (inputPath === outDir || outDir.startsWith(inputPath + path.sep)) { + console.error('La directory di output non può coincidere con il file sorgente.'); + process.exit(1); +} + +console.log('=== TEST HLS DIRETTO ==='); +console.log('Sorgente:', inputPath); +console.log('Output:', outDir); + +let jobId; + +try { + jobId = hlsManager.startJob({ + inputPath, + outDir, + segmentsNeeded: 3 + }); +} catch (err) { + console.error('Errore durante startJob:', err.stack || err.message); + process.exit(1); +} + +console.log('Job ID:', jobId); + +const startedAt = Date.now(); +const timeoutMs = 15 * 60 * 1000; +let timer; + +timer = setInterval(() => { + const job = hlsManager.getJob(jobId); + const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); + + if (!job) { + console.error('Job non trovato nella mappa.'); + clearInterval(timer); + process.exitCode = 1; + return; + } + + const segments = fs.existsSync(outDir) + ? fs.readdirSync(outDir).filter( + name => /^segment_\d+\.ts$/.test(name) + ).length + : 0; + + console.log( + `[${elapsed}s] status=${job.status}` + + ` segments=${segments}` + + ` ready=${job.ready}` + ); + + if (job.finishedAt) { + const playlist = path.join(outDir, 'master.m3u8'); + const playlistExists = fs.existsSync(playlist); + + console.log('\n=== RISULTATO ==='); + console.log('Status:', job.status); + console.log('Exit code:', job.exitCode); + console.log('Segmenti:', segments); + console.log('Playlist presente:', playlistExists); + console.log('Output:', outDir); + + if (playlistExists) { + console.log('\n--- master.m3u8 ---'); + console.log(fs.readFileSync(playlist, 'utf8')); + } + + clearInterval(timer); + process.exitCode = + job.exitCode === 0 && playlistExists && segments > 0 ? 0 : 1; + return; + } + + if (Date.now() - startedAt > timeoutMs) { + console.error('Timeout: superati 15 minuti.'); + clearInterval(timer); + process.exitCode = 124; + } +}, 1000); diff --git a/test_fn/test-hls2.js b/test_fn/test-hls2.js new file mode 100644 index 0000000..df1c695 --- /dev/null +++ b/test_fn/test-hls2.js @@ -0,0 +1,101 @@ +// test-hls2.js +const fs = require('fs'); +const path = require('path'); +const hlsManager = require('./helpers/hlsManager2'); + +const [, , inputArg, outputArg] = process.argv; + +if (!inputArg || !outputArg) { + console.error( + 'Uso: node test-hls.js "/percorso/video.mp4" "/percorso/output-hls"' + ); + process.exit(1); +} + +const inputPath = path.resolve(inputArg); +const outDir = path.resolve(outputArg); + +if (!fs.existsSync(inputPath) || !fs.statSync(inputPath).isFile()) { + console.error(`File video non trovato: ${inputPath}`); + process.exit(1); +} + +if (inputPath === outDir || outDir.startsWith(inputPath + path.sep)) { + console.error('La directory di output non può coincidere con il file sorgente.'); + process.exit(1); +} + +console.log('=== TEST HLS DIRETTO ==='); +console.log('Sorgente:', inputPath); +console.log('Output:', outDir); + +let jobId; + +try { + jobId = hlsManager.startJob({ + inputPath, + outDir, + segmentsNeeded: 3 + }); +} catch (err) { + console.error('Errore durante startJob:', err.stack || err.message); + process.exit(1); +} + +console.log('Job ID:', jobId); + +const startedAt = Date.now(); +const timeoutMs = 15 * 60 * 1000; +let timer; + +timer = setInterval(() => { + const job = hlsManager.getJob(jobId); + const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); + + if (!job) { + console.error('Job non trovato nella mappa.'); + clearInterval(timer); + process.exitCode = 1; + return; + } + + const segments = fs.existsSync(outDir) + ? fs.readdirSync(outDir).filter( + name => /^segment_\d+\.ts$/.test(name) + ).length + : 0; + + console.log( + `[${elapsed}s] status=${job.status}` + + ` segments=${segments}` + + ` ready=${job.ready}` + ); + + if (job.finishedAt) { + const playlist = path.join(outDir, 'master.m3u8'); + const playlistExists = fs.existsSync(playlist); + + console.log('\n=== RISULTATO ==='); + console.log('Status:', job.status); + console.log('Exit code:', job.exitCode); + console.log('Segmenti:', segments); + console.log('Playlist presente:', playlistExists); + console.log('Output:', outDir); + + if (playlistExists) { + console.log('\n--- master.m3u8 ---'); + console.log(fs.readFileSync(playlist, 'utf8')); + } + + clearInterval(timer); + process.exitCode = + job.exitCode === 0 && playlistExists && segments > 0 ? 0 : 1; + return; + } + + if (Date.now() - startedAt > timeoutMs) { + console.error('Timeout: superati 15 minuti.'); + clearInterval(timer); + process.exitCode = 124; + } +}, 1000); diff --git a/test_fn/test_cast_path.js b/test_fn/test_cast_path.js new file mode 100644 index 0000000..630fda8 --- /dev/null +++ b/test_fn/test_cast_path.js @@ -0,0 +1,74 @@ +const fs = require("fs"); +const path = require("path"); +const sharp = require("sharp"); + +const originalFile = + "public/photos/Jessica/original/2021-Santarcangelo/IMG_20210602_100423.jpg"; + +const outputFile = + "public/cast/Jessica/photos/2021-Santarcangelo/IMG_20210602_100423_cast.jpg"; + +async function main() { + console.log("[TEST] Originale:"); + console.log(originalFile); + + if (!fs.existsSync(originalFile)) { + throw new Error("File originale non trovato"); + } + + await fs.promises.mkdir( + path.dirname(outputFile), + { + recursive: true, + } + ); + + const result = await sharp(originalFile, { + failOn: "none", + }) + .rotate() + .resize({ + width: 1920, + height: 1080, + fit: "inside", + withoutEnlargement: true, + }) + .flatten({ + background: { + r: 0, + g: 0, + b: 0, + }, + }) + .jpeg({ + quality: 85, + progressive: true, + chromaSubsampling: "4:2:0", + optimiseCoding: true, + }) + .toFile(outputFile); + + console.log(""); + console.log("[TEST] Cast creato:"); + console.log(outputFile); + + console.log(""); + console.log("[TEST] Dimensioni:"); + console.log(`${result.width}x${result.height}`); + + console.log(""); + console.log("[TEST] Bytes:"); + console.log(result.size); + + console.log(""); + console.log("=========================="); + console.log("TEST OK"); + console.log("=========================="); +} + +main().catch((err) => { + console.error(""); + console.error("TEST FALLITO"); + console.error(err); + process.exit(1); +}); diff --git a/test_fn/transcode_rk_vod.sh b/test_fn/transcode_rk_vod.sh new file mode 100755 index 0000000..d985d6c --- /dev/null +++ b/test_fn/transcode_rk_vod.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +if [ "$#" -ne 2 ]; then + echo "Uso: $0 " + exit 1 +fi + +INPUT="$1" +OUTDIR="$2" + +# Controlli preliminari +if [ ! -f "$INPUT" ]; then + echo "Errore: file di input non trovato: $INPUT" + exit 2 +fi + +# Crea la directory di output se non esiste +mkdir -p "$OUTDIR" +chmod 0755 "$OUTDIR" + +MASTER="${OUTDIR%/}/master.m3u8" +if [ -f "$MASTER" ]; then + echo "Attenzione: master.m3u8 esistente verrà sovrascritto: $MASTER" +fi + +# Rileva disponibilità encoder hardware Rockchip +if ffmpeg -encoders 2>/dev/null | grep -q 'h264_rkmpp'; then + echo "Encoder hardware h264_rkmpp disponibile: userò rkmpp" + HWACCEL_OPTS=(-hwaccel rkmpp) + VIDEO_CODEC="h264_rkmpp" + PIX_FMT="nv12" + EXTRA_CODEC_OPTS=() +else + echo "h264_rkmpp non disponibile: ricaduta su libx264 (software)" + HWACCEL_OPTS=() + VIDEO_CODEC="libx264" + PIX_FMT="yuv420p" + EXTRA_CODEC_OPTS=(-preset veryfast -tune zerolatency -x264-params "nal-hrd=cbr") +fi + +echo "Codec video selezionato: $VIDEO_CODEC" +echo "Formato pixel: $PIX_FMT" + +# Esegui ffmpeg (VOD: mantiene tutti i segmenti) +ffmpeg -hide_banner -y "${HWACCEL_OPTS[@]}" -i "$INPUT" \ + -c:v "$VIDEO_CODEC" "${EXTRA_CODEC_OPTS[@]}" -b:v 5000k -maxrate 6000k -bufsize 12000k \ + -r 30 -g 90 -keyint_min 90 -sc_threshold 0 \ + -pix_fmt "$PIX_FMT" \ + -c:a aac -b:a 160k -ac 2 -ar 48000 \ + -f hls \ + -hls_time 3 -hls_list_size 0 -hls_playlist_type vod \ + -hls_segment_type mpegts \ + -hls_segment_filename "${OUTDIR%/}/segment_%05d.ts" \ + "${MASTER}" diff --git a/test_fn/ws-test.js b/test_fn/ws-test.js new file mode 100644 index 0000000..8244640 --- /dev/null +++ b/test_fn/ws-test.js @@ -0,0 +1,23 @@ +// ws-test.js +const WebSocket = require('ws'); +const token = process.env.TOKEN; // oppure incolla il token qui +const ws = new WebSocket('wss://prova-ws.patachina.it/ws'); + +ws.on('open', () => { + console.log('open -> sending auth'); + ws.send(JSON.stringify({ type: 'auth', token, session_id: 'SESSION_ID_TEST', request_id: 'cli-test-1' })); +}); + +ws.on('message', (m) => { + console.log('recv:', m.toString()); +}); + +ws.on('close', (code, reason) => { + console.log('closed', code, reason && reason.toString()); + process.exit(0); +}); + +ws.on('error', (e) => { + console.error('error', e && e.toString()); + process.exit(1); +}); diff --git a/tools/applyPrototypeSelection.js b/tools/applyPrototypeSelection.js new file mode 100644 index 0000000..ef6078e --- /dev/null +++ b/tools/applyPrototypeSelection.js @@ -0,0 +1,578 @@ +const fs = require('fs'); +const path = require('path'); + +const db = require('../db/knex'); + +function parseArguments(argv) { + const options = { + input: '', + user: '', + person: '', + exclusionPhoto: '', + exclusionReason: 'occlusion_extreme_pose', + exclusionNote: '', + experimentName: '', + persist: false, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + const value = argv[index + 1]; + + if (argument === '--input') { + options.input = String(value || '').trim(); + index += 1; + continue; + } + + if (argument === '--user') { + options.user = String(value || '').trim(); + index += 1; + continue; + } + + if (argument === '--person') { + options.person = String(value || '').trim(); + index += 1; + continue; + } + + if (argument === '--exclude-photo') { + options.exclusionPhoto = String(value || '').trim(); + index += 1; + continue; + } + + if (argument === '--exclusion-reason') { + options.exclusionReason = String(value || '').trim(); + index += 1; + continue; + } + + if (argument === '--exclusion-note') { + options.exclusionNote = String(value || '').trim(); + index += 1; + continue; + } + + if (argument === '--experiment-name') { + options.experimentName = String(value || '').trim(); + index += 1; + continue; + } + + if (argument === '--persist') { + options.persist = true; + continue; + } + + if (argument === '--help' || argument === '-h') { + options.help = true; + continue; + } + + throw new Error(`Argomento sconosciuto: ${argument}`); + } + + if (options.help) { + return options; + } + + if (!options.input) { + throw new Error('--input e obbligatorio'); + } + + if (!options.user) { + throw new Error('--user e obbligatorio'); + } + + if (!options.person) { + throw new Error('--person e obbligatorio'); + } + + if (options.exclusionPhoto && !options.exclusionReason) { + throw new Error('--exclusion-reason non puo essere vuoto'); + } + + return options; +} + +function printHelp() { + console.log([ + 'Uso:', + '', + ' node tools/applyPrototypeSelection.js \\', + ' --input tools/prototypes/jessica-greedy-5.json \\', + ' --user Fabio \\', + ' --person Jessica \\', + ' --exclude-photo IMG_0183.JPG \\', + ' --exclusion-reason occlusion_extreme_pose \\', + ' --exclusion-note "Mano davanti al volto e testa inclinata" \\', + ' [--persist]', + '', + 'Senza --persist:', + ' valida dati, persona, volti e schema;', + ' non modifica il database.', + '', + 'Con --persist:', + ' aggiorna prototype, esclusione di riferimento', + " e stato dell'indice in una transazione.", + ].join('\n')); +} + +function loadExperiment(inputPath) { + const absolutePath = path.resolve(inputPath); + const raw = fs.readFileSync(absolutePath, 'utf8'); + const data = JSON.parse(raw); + const prototypes = data?.prototype_sets?.greedy_gain; + + if (!Array.isArray(prototypes) || prototypes.length === 0) { + throw new Error('prototype_sets.greedy_gain mancante o vuoto'); + } + + const personName = String(data?.person?.name || '').trim(); + + if (!personName) { + throw new Error("Nome persona mancante nell'esperimento"); + } + + const faceIds = new Set(); + + for (const prototype of prototypes) { + const faceId = String(prototype?.face_id || '').trim(); + + if (!faceId) { + throw new Error('Prototype senza face_id'); + } + + if (faceIds.has(faceId)) { + throw new Error(`Prototype duplicato: ${faceId}`); + } + + faceIds.add(faceId); + + const detectorScore = Number(prototype.detector_score); + + if (!Number.isFinite(detectorScore) || detectorScore < 0 || detectorScore > 1) { + throw new Error(`Detector score non valido per ${faceId}`); + } + + if (!prototype.source_type || !prototype.selection_method) { + throw new Error(`Metadati di selezione mancanti per ${faceId}`); + } + } + + return { + absolutePath, + data, + prototypes, + }; +} + +async function ensureRequiredTables() { + const requiredTables = [ + 'persons', + 'faces', + 'photos', + 'person_face_prototypes', + 'person_face_reference_exclusions', + 'face_recognition_index_state', + ]; + + for (const tableName of requiredTables) { + const exists = await db.schema.hasTable(tableName); + + if (!exists) { + throw new Error(`Tabella mancante: ${tableName}`); + } + } +} + +async function findPerson(connection, user, name) { + return connection('persons') + .where({ user }) + .whereRaw('LOWER(name) = LOWER(?)', [name]) + .first(); +} + +async function validatePrototypeFaces(connection, person, user, prototypes) { + const faceIds = prototypes.map((prototype) => prototype.face_id); + + const rows = await connection('faces as f') + .join('photos as p', 'p.id', 'f.photo_id') + .where({ + 'f.person_id': person.id, + 'p.user': user, + }) + .whereIn('f.id', faceIds) + .select( + 'f.id', + 'f.photo_id', + 'f.embedding', + 'f.cluster_id', + 'f.score', + 'p.name as photo_name', + ); + + if (rows.length !== faceIds.length) { + const foundIds = new Set(rows.map((row) => row.id)); + const invalidIds = faceIds.filter((faceId) => !foundIds.has(faceId)); + + throw new Error( + `Prototype non appartenenti alla persona: ${invalidIds.join(', ')}`, + ); + } + + for (const row of rows) { + if (!Buffer.isBuffer(row.embedding) || row.embedding.length !== 512) { + throw new Error(`Embedding non valido per ${row.id}`); + } + } + + return rows; +} + +async function findExclusionFace(connection, person, user, photoName) { + if (!photoName) { + return null; + } + + const rows = await connection('faces as f') + .join('photos as p', 'p.id', 'f.photo_id') + .where({ + 'f.person_id': person.id, + 'p.user': user, + 'p.name': photoName, + }) + .select( + 'f.id', + 'f.photo_id', + 'f.face_index', + 'f.cluster_id', + 'p.name as photo_name', + ) + .orderBy('f.face_index', 'asc'); + + if (rows.length === 0) { + throw new Error(`Volto da escludere non trovato: ${photoName}`); + } + + if (rows.length > 1) { + throw new Error( + `Piu volti della persona trovati in ${photoName}; ` + + 'serve specificare il face_id nel wrapper', + ); + } + + return rows[0]; +} + +function buildPrototypeRow(prototype, person, options, experiment, order, now) { + const metrics = { + metric_delta: prototype.metric_delta || null, + metrics_before: prototype.metrics_before || null, + metrics_after: prototype.metrics_after || null, + photo_name: prototype.photo_name || null, + cluster_label: prototype.cluster_label ?? null, + experiment_person: experiment.data?.person || null, + experiment_parameters: experiment.data?.parameters || null, + }; + + return { + person_id: person.id, + face_id: prototype.face_id, + user: options.user, + source_type: String(prototype.source_type), + selection_method: String(prototype.selection_method), + selection_order: order, + detector_score: Number(prototype.detector_score), + quality_score: null, + active: 1, + experiment_name: options.experimentName, + metrics_json: JSON.stringify(metrics), + created_by: options.user, + created_at: now, + updated_at: now, + }; +} + +async function validateSelection(options, experiment) { + await ensureRequiredTables(); + + const person = await findPerson(db, options.user, options.person); + + if (!person) { + throw new Error(`Persona non trovata: ${options.person}`); + } + + if ( + String(experiment.data.person.name).toLowerCase() + !== options.person.toLowerCase() + ) { + throw new Error( + 'La persona del file esperimento non corrisponde a --person', + ); + } + + const prototypeFaces = await validatePrototypeFaces( + db, + person, + options.user, + experiment.prototypes, + ); + + const exclusionFace = await findExclusionFace( + db, + person, + options.user, + options.exclusionPhoto, + ); + + if ( + exclusionFace + && experiment.prototypes.some( + (prototype) => prototype.face_id === exclusionFace.id, + ) + ) { + throw new Error( + 'Il volto escluso compare anche fra i prototype attivi', + ); + } + + return { + person, + prototypeFaces, + exclusionFace, + }; +} + +async function persistSelection(options, experiment, validation) { + const now = new Date().toISOString(); + + await db.transaction(async (trx) => { + const person = await findPerson(trx, options.user, options.person); + + if (!person) { + throw new Error('Persona non trovata durante la transazione'); + } + + await validatePrototypeFaces( + trx, + person, + options.user, + experiment.prototypes, + ); + + const exclusionFace = await findExclusionFace( + trx, + person, + options.user, + options.exclusionPhoto, + ); + + await trx('person_face_prototypes') + .where({ + person_id: person.id, + user: options.user, + }) + .update({ + active: 0, + updated_at: now, + }); + + for (let index = 0; index < experiment.prototypes.length; index += 1) { + const prototype = experiment.prototypes[index]; + const row = buildPrototypeRow( + prototype, + person, + options, + experiment, + index + 1, + now, + ); + + await trx('person_face_reference_exclusions') + .where({ + person_id: person.id, + face_id: prototype.face_id, + }) + .del(); + + await trx('person_face_prototypes') + .insert(row) + .onConflict(['person_id', 'face_id']) + .merge({ + user: row.user, + source_type: row.source_type, + selection_method: row.selection_method, + selection_order: row.selection_order, + detector_score: row.detector_score, + quality_score: row.quality_score, + active: 1, + experiment_name: row.experiment_name, + metrics_json: row.metrics_json, + created_by: row.created_by, + updated_at: now, + }); + } + + if (exclusionFace) { + await trx('person_face_prototypes') + .where({ + person_id: person.id, + face_id: exclusionFace.id, + }) + .update({ + active: 0, + updated_at: now, + }); + + await trx('person_face_reference_exclusions') + .insert({ + person_id: person.id, + face_id: exclusionFace.id, + user: options.user, + reason_code: options.exclusionReason, + note: options.exclusionNote || null, + created_by: options.user, + created_at: now, + updated_at: now, + }) + .onConflict(['person_id', 'face_id']) + .merge({ + user: options.user, + reason_code: options.exclusionReason, + note: options.exclusionNote || null, + created_by: options.user, + updated_at: now, + }); + } + + const prototypeCountRow = await trx('person_face_prototypes') + .where({ + user: options.user, + active: 1, + }) + .count({ count: '*' }) + .first(); + + const prototypeCount = Number(prototypeCountRow?.count || 0); + + const existingState = await trx('face_recognition_index_state') + .where({ user: options.user }) + .first(); + + if (existingState) { + await trx('face_recognition_index_state') + .where({ user: options.user }) + .update({ + status: 'dirty', + dirty: 1, + prototype_count: prototypeCount, + last_error: null, + updated_at: now, + }); + } else { + await trx('face_recognition_index_state') + .insert({ + user: options.user, + status: 'dirty', + dirty: 1, + version: 0, + prototype_count: prototypeCount, + index_path: null, + metadata_path: null, + built_at: null, + last_error: null, + created_at: now, + updated_at: now, + }); + } + }); + + return { + person_id: validation.person.id, + person_name: validation.person.name, + prototype_count: experiment.prototypes.length, + exclusion_face_id: validation.exclusionFace?.id || null, + index_dirty: true, + }; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + + if (options.help) { + printHelp(); + return; + } + + const experiment = loadExperiment(options.input); + + if (!options.experimentName) { + options.experimentName = path.basename( + experiment.absolutePath, + path.extname(experiment.absolutePath), + ); + } + + const validation = await validateSelection(options, experiment); + + console.log({ + mode: options.persist ? 'persist' : 'dry-run', + user: options.user, + person: { + id: validation.person.id, + name: validation.person.name, + }, + experiment: options.experimentName, + prototype_count: experiment.prototypes.length, + prototypes: experiment.prototypes.map((prototype, index) => ({ + order: index + 1, + face_id: prototype.face_id, + photo_name: prototype.photo_name, + source_type: prototype.source_type, + cluster_label: prototype.cluster_label, + selection_method: prototype.selection_method, + })), + reference_exclusion: validation.exclusionFace + ? { + face_id: validation.exclusionFace.id, + photo_name: validation.exclusionFace.photo_name, + reason_code: options.exclusionReason, + note: options.exclusionNote, + } + : null, + }); + + if (!options.persist) { + console.log( + '[prototypeSelection] dry-run completato. Database non modificato.', + ); + return; + } + + const result = await persistSelection( + options, + experiment, + validation, + ); + + console.log( + '[prototypeSelection] selezione persistita:', + result, + ); +} + +main() + .catch((error) => { + console.error( + '[prototypeSelection] errore:', + error.stack || error.message || error, + ); + process.exitCode = 1; + }) + .finally(async () => { + await db.destroy(); + }); diff --git a/tools/cleanupCast.js b/tools/cleanupCast.js new file mode 100644 index 0000000..3a02155 --- /dev/null +++ b/tools/cleanupCast.js @@ -0,0 +1,463 @@ +// tools/cleanupCast.js + +const fs = require('fs'); +const path = require('path'); + +const CAST_ROOT = path.join( + process.cwd(), + 'public', + 'cast' +); + +const MIN_AGE_MS = + 24 * 60 * 60 * 1000; + + +function getAgeMs(stat, now) { + return now - stat.mtimeMs; +} + + +function formatAge(ageMs) { + return ( + ageMs / + (60 * 60 * 1000) + ).toFixed(2); +} + + +/* + * ============================================================ + * FOTO CAST + * ============================================================ + * + * Ricerca ricorsivamente sotto: + * + * public/cast//photos/ + * + * e cancella soltanto file con almeno 24 ore. + */ +async function cleanupPhotos({ + photosDir, + dryRun, + now, + counters, +}) { + let entries; + + try { + entries = await fs.promises.readdir( + photosDir, + { + withFileTypes: true, + }, + ); + } catch (error) { + if (error.code === 'ENOENT') { + return; + } + + throw error; + } + + for (const entry of entries) { + const fullPath = path.join( + photosDir, + entry.name, + ); + + if (entry.isDirectory()) { + await cleanupPhotos({ + photosDir: fullPath, + dryRun, + now, + counters, + }); + + /* + * Rimuove eventuali directory vuote. + */ + try { + const remaining = + await fs.promises.readdir(fullPath); + + if (remaining.length === 0) { + if (dryRun) { + console.log( + '[cleanupCast] WOULD REMOVE EMPTY DIR', + fullPath, + ); + } else { + await fs.promises.rmdir(fullPath); + + console.log( + '[cleanupCast] removed empty dir', + fullPath, + ); + } + } + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + continue; + } + + if (!entry.isFile()) { + continue; + } + + const stat = + await fs.promises.stat(fullPath); + + const ageMs = + getAgeMs(stat, now); + + counters.photoFilesChecked += 1; + + if (ageMs < MIN_AGE_MS) { + console.log( + '[cleanupCast] KEEP photo', + `age=${formatAge(ageMs)}h`, + fullPath, + ); + + counters.kept += 1; + continue; + } + + if (dryRun) { + console.log( + '[cleanupCast] WOULD DELETE photo', + `age=${formatAge(ageMs)}h`, + fullPath, + ); + } else { + await fs.promises.unlink(fullPath); + + console.log( + '[cleanupCast] deleted photo', + `age=${formatAge(ageMs)}h`, + fullPath, + ); + } + + counters.deleted += 1; + } +} + + +/* + * ============================================================ + * VIDEO CAST + * ============================================================ + * + * Determina la data piu recente di tutti i file presenti + * nella directory HLS. + * + * In questo modo un video viene eliminato soltanto quando + * tutti i file che lo compongono hanno almeno 24 ore. + */ +async function getNewestMtimeMs(dir) { + const entries = + await fs.promises.readdir( + dir, + { + withFileTypes: true, + }, + ); + + let newest = 0; + + for (const entry of entries) { + const fullPath = + path.join(dir, entry.name); + + if (entry.isDirectory()) { + const childNewest = + await getNewestMtimeMs(fullPath); + + newest = + Math.max( + newest, + childNewest, + ); + + continue; + } + + if (!entry.isFile()) { + continue; + } + + const stat = + await fs.promises.stat(fullPath); + + newest = + Math.max( + newest, + stat.mtimeMs, + ); + } + + return newest; +} + + +/* + * Identifica le directory che rappresentano + * effettivamente un video HLS cercando master.m3u8. + */ +async function findVideoDirs( + videosRoot, + result = [], +) { + let entries; + + try { + entries = + await fs.promises.readdir( + videosRoot, + { + withFileTypes: true, + }, + ); + } catch (error) { + if (error.code === 'ENOENT') { + return result; + } + + throw error; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const fullPath = + path.join( + videosRoot, + entry.name, + ); + + const masterPath = + path.join( + fullPath, + 'master.m3u8', + ); + + try { + const stat = + await fs.promises.stat( + masterPath, + ); + + if (stat.isFile()) { + result.push(fullPath); + continue; + } + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + await findVideoDirs( + fullPath, + result, + ); + } + + return result; +} + + +async function cleanupVideos({ + videosRoot, + dryRun, + now, + counters, +}) { + const videoDirs = + await findVideoDirs( + videosRoot, + ); + + for (const videoDir of videoDirs) { + counters.videoDirsChecked += 1; + + const newestMtimeMs = + await getNewestMtimeMs( + videoDir, + ); + + if (!newestMtimeMs) { + continue; + } + + const ageMs = + now - newestMtimeMs; + + if (ageMs < MIN_AGE_MS) { + console.log( + '[cleanupCast] KEEP video', + `age=${formatAge(ageMs)}h`, + videoDir, + ); + + counters.kept += 1; + continue; + } + + if (dryRun) { + console.log( + '[cleanupCast] WOULD DELETE video', + `age=${formatAge(ageMs)}h`, + videoDir, + ); + } else { + await fs.promises.rm( + videoDir, + { + recursive: true, + force: true, + }, + ); + + console.log( + '[cleanupCast] deleted video', + `age=${formatAge(ageMs)}h`, + videoDir, + ); + } + + counters.deleted += 1; + } +} + + +/* + * ============================================================ + * CLEANUP GENERALE + * ============================================================ + */ +async function cleanupCast({ + dryRun = false, +} = {}) { + const startedAt = Date.now(); + const now = Date.now(); + + const counters = { + photoFilesChecked: 0, + videoDirsChecked: 0, + kept: 0, + deleted: 0, + }; + + console.log( + '[cleanupCast] start', + dryRun ? 'DRY-RUN' : 'LIVE', + ); + + console.log( + '[cleanupCast] root:', + CAST_ROOT, + ); + + if (!fs.existsSync(CAST_ROOT)) { + console.log( + '[cleanupCast] cast root does not exist' + ); + + return counters; + } + + const users = + await fs.promises.readdir( + CAST_ROOT, + { + withFileTypes: true, + }, + ); + + for (const userEntry of users) { + if (!userEntry.isDirectory()) { + continue; + } + + const userRoot = + path.join( + CAST_ROOT, + userEntry.name, + ); + + await cleanupPhotos({ + photosDir: path.join( + userRoot, + 'photos', + ), + dryRun, + now, + counters, + }); + + await cleanupVideos({ + videosRoot: path.join( + userRoot, + 'videos', + ), + dryRun, + now, + counters, + }); + } + + console.log( + '[cleanupCast] completed', + { + ...counters, + dryRun, + elapsedMs: + Date.now() - startedAt, + }, + ); + + return counters; +} + + +/* + * Esecuzione standalone: + * + * node tools/cleanupCast.js --dry-run + * node tools/cleanupCast.js + */ +if (require.main === module) { + const dryRun = + process.argv.includes('--dry-run'); + + cleanupCast({ + dryRun, + }) + .then(() => { + process.exitCode = 0; + }) + .catch((error) => { + console.error( + '[cleanupCast] fatal error', + error, + ); + + process.exitCode = 1; + }); +} + + +module.exports = { + cleanupCast, + MIN_AGE_MS, + CAST_ROOT, +}; diff --git a/tools/clustering/face_hdbscan.py b/tools/clustering/face_hdbscan.py new file mode 100755 index 0000000..71014fd --- /dev/null +++ b/tools/clustering/face_hdbscan.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 + +import argparse +import json +import math +import sqlite3 +import sys +from collections import Counter, defaultdict +from pathlib import Path + +import numpy as np +from sklearn.cluster import HDBSCAN + + +EMBEDDING_DIMENSIONS = 128 +EMBEDDING_BYTES = EMBEDDING_DIMENSIONS * 4 + + +def parse_arguments(): + parser = argparse.ArgumentParser( + description=( + "Esegue HDBSCAN sugli embedding facciali " + "senza modificare il database." + ), + ) + + parser.add_argument( + "--database", + required=True, + help="Percorso del database SQLite", + ) + + parser.add_argument( + "--user", + required=True, + help="Utente proprietario dei volti", + ) + + parser.add_argument( + "--min-cluster-size", + type=int, + default=2, + ) + + parser.add_argument( + "--min-samples", + type=int, + default=1, + ) + + parser.add_argument( + "--metric", + default="euclidean", + ) + + parser.add_argument( + "--selection-method", + choices=("eom", "leaf"), + default="eom", + ) + + parser.add_argument( + "--scope", + choices=("all", "unclustered"), + default="all", + ) + + parser.add_argument( + "--pretty", + action="store_true", + ) + + arguments = parser.parse_args() + + if arguments.min_cluster_size < 2: + parser.error( + "--min-cluster-size deve essere almeno 2" + ) + + if arguments.min_samples < 1: + parser.error( + "--min-samples deve essere almeno 1" + ) + + return arguments + + +def load_faces(database_path, scope, user): + connection = sqlite3.connect(database_path) + connection.row_factory = sqlite3.Row + + query = """ + SELECT + f.id, + f.photo_id, + f.face_index, + f.score, + f.embedding, + p.name AS photo_name, + p.path AS photo_path, + COALESCE(p.rotation, 0) AS photo_rotation + FROM faces AS f + JOIN photos AS p + ON p.id = f.photo_id + WHERE f.embedding IS NOT NULL + AND p.user = ? + """ + + parameters = [user] + + if scope == "unclustered": + query += """ + AND f.cluster_id IS NULL + """ + + query += """ + ORDER BY + f.id + """ + + rows = connection.execute( + query, + parameters, + ).fetchall() + + connection.close() + + if len(rows) < 2: + raise RuntimeError( + "Sono necessari almeno due embedding" + ) + + faces = [] + embeddings = [] + + for row in rows: + blob = row["embedding"] + + if len(blob) != EMBEDDING_BYTES: + raise RuntimeError( + "Embedding non valido per " + f"{row['id']}: {len(blob)} byte" + ) + + vector = np.frombuffer( + blob, + dtype="= 0: + cluster_indices[label].append(index) + + clusters = [] + + for label in sorted(cluster_indices): + indices = cluster_indices[label] + + members = [] + + for index in indices: + face = dict(faces[index]) + + face["probability"] = float( + probabilities[index] + ) + + members.append(face) + + clusters.append( + { + "label": label, + "size": len(indices), + "probability": { + "minimum": float( + min( + probabilities[index] + for index in indices + ) + ), + "maximum": float( + max( + probabilities[index] + for index in indices + ) + ), + "mean": float( + sum( + probabilities[index] + for index in indices + ) + / len(indices) + ), + }, + "cosine_similarity": cosine_statistics( + indices, + matrix, + ), + "members": members, + } + ) + + noise_members = [] + + for index, label_value in enumerate(labels): + if int(label_value) != -1: + continue + + face = dict(faces[index]) + face["probability"] = float( + probabilities[index] + ) + + noise_members.append(face) + + total_faces = len(faces) + noise_count = label_counts.get(-1, 0) + + return { + "dry_run": True, + "database": str(arguments.database), + "parameters": { + "min_cluster_size": + arguments.min_cluster_size, + "min_samples": + arguments.min_samples, + "metric": arguments.metric, + "cluster_selection_method": + arguments.selection_method, + "scope": arguments.scope, + "user": arguments.user, + }, + "summary": { + "total_faces": total_faces, + "cluster_count": len(clusters), + "clustered_faces": + total_faces - noise_count, + "noise_faces": noise_count, + "noise_percent": ( + 100.0 * noise_count / total_faces + ), + }, + "clusters": clusters, + "noise": noise_members, + } + + +def main(): + arguments = parse_arguments() + + database_path = Path( + arguments.database + ).expanduser().resolve() + + if not database_path.is_file(): + raise RuntimeError( + f"Database non trovato: {database_path}" + ) + + faces, matrix = load_faces( + database_path, + arguments.scope, + arguments.user, + ) + + clusterer = HDBSCAN( + min_cluster_size= + arguments.min_cluster_size, + min_samples=arguments.min_samples, + metric=arguments.metric, + cluster_selection_method= + arguments.selection_method, + allow_single_cluster=False, + copy=True, + ) + + labels = clusterer.fit_predict(matrix) + probabilities = clusterer.probabilities_ + + result = build_result( + arguments, + faces, + matrix, + labels, + probabilities, + ) + + json.dump( + result, + sys.stdout, + indent=2 if arguments.pretty else None, + ensure_ascii=False, + ) + + sys.stdout.write("\n") + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print( + f"[face_hdbscan] errore: {error}", + file=sys.stderr, + ) + + raise diff --git a/tools/enqueueFaceScanJobs.js b/tools/enqueueFaceScanJobs.js new file mode 100644 index 0000000..bc6f49b --- /dev/null +++ b/tools/enqueueFaceScanJobs.js @@ -0,0 +1,139 @@ +const db = require('../db/knex'); + +function parseTarget(value) { + if (value == null) { + return null; + } + + const target = Number(value); + + if ( + !Number.isInteger(target) + || target <= 0 + ) { + throw new Error( + 'Il limite deve essere un intero positivo', + ); + } + + return target; +} + +async function selectPhotosWithoutJob(limit) { + let query = db('photos as p') + .leftJoin( + 'face_scan_jobs as j', + 'j.photo_id', + 'p.id', + ) + .select('p.id') + .whereNull('p.deleted_at') + .whereNotNull('p.path') + .where('p.path', '!=', '') + .whereNull('j.photo_id') + .andWhere((builder) => { + builder + .whereNull('p.mime_type') + .orWhere( + 'p.mime_type', + 'like', + 'image/%', + ); + }) + .orderBy('p.created_at', 'asc') + .orderBy('p.id', 'asc'); + + if (limit !== null) { + query = query.limit(limit); + } + + return query; +} + +async function main() { + const target = parseTarget(process.argv[2]); + + const existingResult = await db('face_scan_jobs') + .count({ count: '*' }) + .first(); + + const existingJobs = + Number(existingResult?.count || 0); + + let slots = null; + + if (target !== null) { + slots = Math.max( + 0, + target - existingJobs, + ); + } + + if (slots === 0) { + console.log({ + target_jobs: target, + jobs_before: existingJobs, + photos_selected: 0, + jobs_inserted: 0, + jobs_after: existingJobs, + }); + + return; + } + + const photos = await selectPhotosWithoutJob( + slots, + ); + + let inserted = 0; + + await db.transaction(async (trx) => { + for (const photo of photos) { + await trx('face_scan_jobs').insert({ + photo_id: photo.id, + status: 'pending', + attempts: 0, + faces_detected: null, + execution_ms: null, + applied_rotation: null, + last_error: null, + started_at: null, + completed_at: null, + created_at: trx.fn.now(), + updated_at: trx.fn.now(), + }); + + inserted += 1; + } + }); + + const finalResult = await db('face_scan_jobs') + .count({ count: '*' }) + .first(); + + const finalJobs = + Number(finalResult?.count || 0); + + console.log({ + target_jobs: target, + jobs_before: existingJobs, + photos_selected: photos.length, + jobs_inserted: inserted, + jobs_after: finalJobs, + }); +} + +main() + .catch((error) => { + console.error( + '[enqueueFaceScanJobs] errore:', + error.stack + || error.message + || error, + ); + + process.exitCode = 1; + }) + .finally(async () => { + await db.destroy(); + }); diff --git a/tools/faceKnnRuntime.js b/tools/faceKnnRuntime.js new file mode 100644 index 0000000..0685528 --- /dev/null +++ b/tools/faceKnnRuntime.js @@ -0,0 +1,534 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const db = require('../db/knex'); + +const PROJECT_ROOT = path.resolve(__dirname, '..'); + +const CONFIG_PATH = path.join( + PROJECT_ROOT, + 'native', + 'face-knn', + 'config.json', +); + +function loadConfig() { + const data = JSON.parse( + fs.readFileSync(CONFIG_PATH, 'utf8'), + ); + + return { + enabled: + data.enabled !== false, + + mode: + String(data.mode || 'shadow'), + + backend: + String(data.backend || 'opencl'), + + fallbackBackend: + String(data.fallbackBackend || 'cpu'), + + dimension: + Number(data.dimension || 128), + + topK: + Number(data.topK || 5), + + recognitionThreshold: + Number(data.recognitionThreshold ?? 0.50), + + scoreTolerance: + Number(data.scoreTolerance ?? 0.00001), + + launcher: + path.resolve( + PROJECT_ROOT, + String( + data.launcher + || 'native/face-knn/run-face-knn.sh' + ), + ), + + runtimeDirectory: + path.resolve( + PROJECT_ROOT, + String( + data.runtimeDirectory + || 'var/face-knn' + ), + ), + }; +} + +function validateEmbedding( + embedding, + faceId, + dimension, +) { + if (!Buffer.isBuffer(embedding)) { + throw new Error( + `Embedding non Buffer per ${faceId}`, + ); + } + + const expectedBytes = + dimension * Float32Array.BYTES_PER_ELEMENT; + + if (embedding.length !== expectedBytes) { + throw new Error( + `Embedding non valido per ${faceId}: ` + + `${embedding.length} byte, ` + + `attesi ${expectedBytes}`, + ); + } +} + +function normalizeFaceIds(faceIds, label) { + if (!Array.isArray(faceIds) || faceIds.length === 0) { + throw new Error( + `${label} mancante o vuoto`, + ); + } + + const normalized = faceIds.map( + (faceId) => String(faceId || '').trim(), + ); + + if (normalized.some((faceId) => !faceId)) { + throw new Error( + `${label} contiene face_id non valido`, + ); + } + + if (new Set(normalized).size !== normalized.length) { + throw new Error( + `${label} contiene face_id duplicati`, + ); + } + + return normalized; +} + +async function loadFacesByIds( + connection, + faceIds, + dimension, +) { + const normalizedIds = normalizeFaceIds( + faceIds, + 'faceIds', + ); + + const rows = await connection('faces as face') + .join( + 'photos as photo', + 'photo.id', + 'face.photo_id', + ) + .whereIn('face.id', normalizedIds) + .whereNotNull('face.embedding') + .select( + 'face.id as face_id', + 'face.embedding', + 'face.face_index', + 'photo.name as photo_name', + ); + + const byId = new Map( + rows.map((row) => [ + row.face_id, + row, + ]), + ); + + return normalizedIds.map((faceId) => { + const row = byId.get(faceId); + + if (!row) { + throw new Error( + `Volto o embedding mancante: ${faceId}`, + ); + } + + validateEmbedding( + row.embedding, + faceId, + dimension, + ); + + return row; + }); +} + +async function loadActivePrototypes( + connection, + personId, + user, + dimension, +) { + const rows = await connection( + 'person_face_prototypes as prototype', + ) + .join( + 'faces as face', + 'face.id', + 'prototype.face_id', + ) + .join( + 'photos as photo', + 'photo.id', + 'face.photo_id', + ) + .where({ + 'prototype.person_id': personId, + 'prototype.user': user, + 'prototype.active': 1, + }) + .whereNotNull('face.embedding') + .orderBy( + 'prototype.selection_order', + 'asc', + ) + .select( + 'prototype.selection_order', + 'prototype.selection_method', + 'prototype.face_id', + 'face.embedding', + 'face.face_index', + 'photo.name as photo_name', + ); + + if (rows.length === 0) { + throw new Error( + 'Nessun prototype attivo disponibile', + ); + } + + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]; + + validateEmbedding( + row.embedding, + row.face_id, + dimension, + ); + + const expectedOrder = index + 1; + + if ( + Number(row.selection_order) + !== expectedOrder + ) { + throw new Error( + 'selection_order non contiguo: ' + + `atteso ${expectedOrder}, ` + + `trovato ${row.selection_order}`, + ); + } + } + + return rows; +} + +function writeBinaryFile(filePath, rows) { + fs.writeFileSync( + filePath, + Buffer.concat( + rows.map((row) => row.embedding), + ), + ); +} + +function buildSelfMap( + queryRows, + prototypeRows, +) { + const prototypeIndexByFaceId = + new Map( + prototypeRows.map( + (prototype, index) => [ + prototype.face_id, + index, + ], + ), + ); + + return queryRows.map((query) => { + const index = + prototypeIndexByFaceId.get( + query.face_id, + ); + + return index == null + ? -1 + : index; + }); +} + +function writeSelfMap( + filePath, + selfMap, +) { + fs.writeFileSync( + filePath, + `${selfMap.join('\n')}\n`, + 'utf8', + ); +} + +function runNativeKnn({ + config, + queryPath, + databasePath, + selfMapPath, + queryCount, + databaseCount, + backend, +}) { + const args = [ + '--queries', + queryPath, + '--database', + databasePath, + '--query-count', + String(queryCount), + '--database-count', + String(databaseCount), + '--dimension', + String(config.dimension), + '--top-k', + String( + Math.min( + config.topK, + databaseCount, + ), + ), + '--threshold', + String(config.recognitionThreshold), + '--self-map', + selfMapPath, + '--backend', + backend, + ]; + + const result = spawnSync( + config.launcher, + args, + { + cwd: PROJECT_ROOT, + encoding: 'utf8', + stdio: [ + 'ignore', + 'pipe', + 'pipe', + ], + maxBuffer: 16 * 1024 * 1024, + }, + ); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + throw new Error( + `face_knn ${backend} fallito ` + + `(status=${result.status}):\n` + + String(result.stderr || '') + + String(result.stdout || ''), + ); + } + + let output; + + try { + output = JSON.parse( + String(result.stdout || ''), + ); + } catch (error) { + throw new Error( + 'Output JSON face_knn non valido: ' + + error.message + + '\nstdout:\n' + + String(result.stdout || '').slice(0, 2000) + + '\nstderr:\n' + + String(result.stderr || '').slice(0, 2000), + ); + } + + return { + output, + stderr: + String(result.stderr || ''), + command: { + launcher: config.launcher, + args, + }, + }; +} + +async function runFaceKnn({ + connection = db, + personId, + user, + queryFaceIds, + backend, + keepTemporaryFiles = false, +}) { + const config = loadConfig(); + + if (!config.enabled) { + throw new Error( + 'face_knn disabilitato dalla configurazione', + ); + } + + const normalizedQueryIds = + normalizeFaceIds( + queryFaceIds, + 'queryFaceIds', + ); + + const [prototypeRows, queryRows] = + await Promise.all([ + loadActivePrototypes( + connection, + personId, + user, + config.dimension, + ), + loadFacesByIds( + connection, + normalizedQueryIds, + config.dimension, + ), + ]); + + fs.mkdirSync( + config.runtimeDirectory, + { + recursive: true, + }, + ); + + const temporaryDirectory = + fs.mkdtempSync( + path.join( + config.runtimeDirectory, + 'run-', + ), + ); + + const queryPath = + path.join( + temporaryDirectory, + 'queries.bin', + ); + + const databasePath = + path.join( + temporaryDirectory, + 'prototypes.bin', + ); + + const selfMapPath = + path.join( + temporaryDirectory, + 'self-map.txt', + ); + + const selfMap = + buildSelfMap( + queryRows, + prototypeRows, + ); + + try { + writeBinaryFile( + queryPath, + queryRows, + ); + + writeBinaryFile( + databasePath, + prototypeRows, + ); + + writeSelfMap( + selfMapPath, + selfMap, + ); + + const nativeResult = + runNativeKnn({ + config, + queryPath, + databasePath, + selfMapPath, + queryCount: + queryRows.length, + databaseCount: + prototypeRows.length, + backend: + backend || config.backend, + }); + + return { + config, + output: + nativeResult.output, + stderr: + nativeResult.stderr, + command: + nativeResult.command, + queryRows: + queryRows.map((row, index) => ({ + index, + face_id: row.face_id, + face_index: row.face_index, + photo_name: row.photo_name, + excluded_index: + selfMap[index], + })), + prototypeRows: + prototypeRows.map( + (row, index) => ({ + index, + face_id: row.face_id, + face_index: row.face_index, + photo_name: row.photo_name, + selection_order: + Number(row.selection_order), + selection_method: + row.selection_method, + }), + ), + temporaryDirectory: + keepTemporaryFiles + ? temporaryDirectory + : null, + }; + } finally { + if (!keepTemporaryFiles) { + fs.rmSync( + temporaryDirectory, + { + recursive: true, + force: true, + }, + ); + } + } +} + +module.exports = { + loadConfig, + loadFacesByIds, + loadActivePrototypes, + buildSelfMap, + runFaceKnn, +}; diff --git a/tools/faceScanStatus.js b/tools/faceScanStatus.js new file mode 100644 index 0000000..f0122fc --- /dev/null +++ b/tools/faceScanStatus.js @@ -0,0 +1,142 @@ +const fs = require('fs'); +const path = require('path'); + +const db = require('../db/knex'); + +const LOCK_PATH = path.resolve( + __dirname, + '..', + 'face-scan-daemon.lock', +); + +function readLock() { + try { + const text = fs.readFileSync( + LOCK_PATH, + 'utf8', + ); + + return JSON.parse(text); + } catch { + return null; + } +} + +function isProcessAlive(pidValue) { + const pid = Number(pidValue); + + if ( + !Number.isInteger(pid) + || pid <= 0 + ) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code === 'EPERM'; + } +} + +async function getQueueStatus() { + const rows = await db('face_scan_jobs') + .select('status') + .count({ count: '*' }) + .groupBy('status'); + + const queue = { + pending: 0, + processing: 0, + done: 0, + error: 0, + }; + + for (const row of rows) { + queue[row.status] = + Number(row.count || 0); + } + + return queue; +} + +async function getFaceStatistics() { + const row = await db('face_scan_jobs') + .select( + db.raw( + 'SUM(COALESCE(faces_detected, 0)) ' + + 'AS total_faces', + ), + db.raw( + 'SUM(attempts) AS total_attempts', + ), + db.raw( + 'ROUND(AVG(execution_ms), 1) ' + + 'AS average_execution_ms', + ), + ) + .first(); + + return { + total_faces: + Number(row?.total_faces || 0), + + total_attempts: + Number(row?.total_attempts || 0), + + average_execution_ms: + row?.average_execution_ms == null + ? null + : Number(row.average_execution_ms), + }; +} + +async function main() { + const queue = await getQueueStatus(); + const statistics = + await getFaceStatistics(); + + const lock = readLock(); + const daemonAlive = + lock != null + && isProcessAlive(lock.pid); + + const result = { + daemon: { + running: daemonAlive, + lock_exists: lock != null, + lock_path: LOCK_PATH, + pid: lock?.pid ?? null, + started_at: + lock?.started_at ?? null, + project_root: + lock?.project_root ?? null, + stale_lock: + lock != null && !daemonAlive, + }, + + queue, + + statistics, + }; + + console.log( + JSON.stringify(result, null, 2), + ); +} + +main() + .catch((error) => { + console.error( + '[faceScanStatus] errore:', + error.stack + || error.message + || error, + ); + + process.exitCode = 1; + }) + .finally(async () => { + await db.destroy(); + }); diff --git a/tools/patch_prototype_gallery.py b/tools/patch_prototype_gallery.py new file mode 100644 index 0000000..ce193e9 --- /dev/null +++ b/tools/patch_prototype_gallery.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +from pathlib import Path + +js_path = Path("public/js/faces.js") +css_path = Path("public/css/faces.css") +js = js_path.read_text(encoding="utf-8") +css = css_path.read_text(encoding="utf-8") +start = js.find(" function createPersonFaceCard(\n") +end = js.find("\n\n function renderPersons() {", start) +if start < 0 or end < 0: + raise SystemExit("ERRORE: blocco dettaglio persona non trovato") +replacement = ' function createPersonFaceCard(person, face, overlay) {\n const card = createFaceCard(face);\n const body = card.querySelector(".face-body");\n const wrapper = card.querySelector(".face-image-wrapper");\n const badges = document.createElement("div");\n badges.className = "person-face-badges";\n\n const addBadge = (text, className) => {\n const badge = document.createElement("span");\n badge.className = `person-face-badge ${className}`;\n badge.textContent = text;\n badges.appendChild(badge);\n };\n\n if (face.is_prototype) {\n const order = face.prototype?.selection_order;\n addBadge(order == null ? "Prototype" : `Prototype #${order}`, "prototype-badge");\n }\n\n if (face.is_reference_excluded) {\n addBadge("Caso difficile", "difficult-badge");\n }\n\n if (face.cluster_label == null) {\n addBadge("Noise confermato", "noise-badge");\n } else {\n addBadge(`Cluster ${face.cluster_label}`, "cluster-badge");\n }\n\n if (wrapper) wrapper.appendChild(badges);\n\n const info = document.createElement("p");\n info.className = "face-details";\n info.textContent = face.cluster_label == null\n ? "Senza cluster automatico"\n : `Cluster ${face.cluster_label}`;\n\n const referenceInfo = document.createElement("div");\n referenceInfo.className = "reference-info";\n\n if (face.is_prototype) {\n referenceInfo.classList.add("prototype-info");\n referenceInfo.textContent = face.prototype?.selection_method === "cluster_medoid"\n ? "Riferimento centrale del cluster"\n : "Riferimento scelto per copertura";\n } else if (face.is_reference_excluded) {\n referenceInfo.classList.add("difficult-info");\n referenceInfo.textContent = face.reference_exclusion?.note\n || face.reference_exclusion?.reason_code\n || "Non usare come riferimento";\n } else {\n referenceInfo.hidden = true;\n }\n\n const removeButton = document.createElement("button");\n removeButton.type = "button";\n removeButton.className = "danger-button";\n removeButton.textContent = "Rimuovi dalla persona";\n removeButton.addEventListener("click", async () => {\n removeButton.disabled = true;\n try {\n await removeFaceFromPerson(person, face);\n overlay.remove();\n await loadData();\n setStatus("Volto rimosso dalla persona", "success");\n } catch (error) {\n console.error("[faces] rimozione volto:", error);\n setStatus(error.message, "error");\n removeButton.disabled = false;\n }\n });\n\n if (body) body.append(info, referenceInfo, removeButton);\n return card;\n }\n\n\n function personFaceMatches(face, filter) {\n if (filter === "prototype") return Boolean(face.is_prototype);\n if (filter === "clustered") return face.cluster_id != null;\n if (filter === "noise") return face.cluster_id == null;\n if (filter === "difficult") return Boolean(face.is_reference_excluded);\n return true;\n }\n\n\n async function openPersonDetail(personId) {\n closePersonDetail();\n setStatus("Caricamento della persona...");\n\n const data = await apiRequest(`/face-review/persons/${encodeURIComponent(personId)}`);\n const person = data.person;\n const summary = data.summary || {};\n const faces = data.faces || [];\n const indexState = data.index_state || {};\n\n const overlay = document.createElement("div");\n overlay.id = "personDetailOverlay";\n overlay.className = "person-detail-overlay";\n\n const dialog = document.createElement("section");\n dialog.className = "person-detail-dialog";\n\n const header = document.createElement("header");\n header.className = "person-detail-header";\n const heading = document.createElement("div");\n const title = document.createElement("h2");\n title.textContent = person.name;\n const summaryText = document.createElement("p");\n summaryText.className = "page-subtitle";\n summaryText.textContent = `${summary.face_count || 0} volti | ${summary.cluster_count || 0} cluster | ${summary.prototype_count || 0} prototype | ${summary.difficult_face_count || 0} casi difficili`;\n heading.append(title, summaryText);\n\n const closeButton = document.createElement("button");\n closeButton.type = "button";\n closeButton.className = "secondary-button";\n closeButton.textContent = "Chiudi";\n closeButton.addEventListener("click", closePersonDetail);\n header.append(heading, closeButton);\n\n const indexPanel = document.createElement("section");\n indexPanel.className = "recognition-index-panel";\n const indexTitle = document.createElement("strong");\n indexTitle.textContent = "Indice riconoscimento";\n const indexStatus = document.createElement("span");\n indexStatus.className = indexState.dirty ? "index-status dirty" : "index-status ready";\n indexStatus.textContent = indexState.dirty ? "Da aggiornare" : "Aggiornato";\n const indexDetails = document.createElement("span");\n indexDetails.className = "index-state-details";\n indexDetails.textContent = `Versione ${indexState.version || 0} | ${indexState.prototype_count || 0} prototype`;\n indexPanel.append(indexTitle, indexStatus, indexDetails);\n\n const renameArea = document.createElement("div");\n renameArea.className = "person-rename-area";\n const nameInput = document.createElement("input");\n nameInput.type = "text";\n nameInput.maxLength = 120;\n nameInput.value = person.name;\n nameInput.className = "person-name-input";\n const renameButton = document.createElement("button");\n renameButton.type = "button";\n renameButton.className = "primary-button";\n renameButton.textContent = "Rinomina";\n renameButton.addEventListener("click", async () => {\n renameButton.disabled = true;\n try {\n await renamePerson(person, nameInput);\n overlay.remove();\n await loadData();\n setStatus("Persona rinominata", "success");\n } catch (error) {\n console.error("[faces] rinomina:", error);\n setStatus(error.message, "error");\n renameButton.disabled = false;\n }\n });\n renameArea.append(nameInput, renameButton);\n\n const faceGrid = document.createElement("div");\n faceGrid.className = "face-grid person-detail-faces";\n faces.forEach((face) => faceGrid.appendChild(createPersonFaceCard(person, face, overlay)));\n\n const filters = document.createElement("nav");\n filters.className = "person-face-filters";\n const definitions = [\n ["all", "Tutti", summary.face_count || faces.length],\n ["prototype", "Prototype", summary.prototype_count || 0],\n ["clustered", "Clusterizzati", summary.clustered_face_count || 0],\n ["noise", "Noise confermati", summary.confirmed_noise_count || 0],\n ["difficult", "Casi difficili", summary.difficult_face_count || 0],\n ];\n const buttons = [];\n definitions.forEach(([filter, label, count]) => {\n const button = document.createElement("button");\n button.type = "button";\n button.className = "person-face-filter";\n if (filter === "all") button.classList.add("active");\n button.textContent = `${label} ${count}`;\n button.addEventListener("click", () => {\n buttons.forEach((item) => item.classList.toggle("active", item === button));\n Array.from(faceGrid.children).forEach((card, index) => {\n card.classList.toggle("hidden", !personFaceMatches(faces[index], filter));\n });\n });\n buttons.push(button);\n filters.appendChild(button);\n });\n\n dialog.append(header, indexPanel, renameArea, filters, faceGrid);\n overlay.appendChild(dialog);\n overlay.addEventListener("click", (event) => {\n if (event.target === overlay) closePersonDetail();\n });\n document.body.appendChild(overlay);\n setStatus(`Dettaglio di ${person.name}`, "success");\n }' +js = js[:start] + replacement + js[end:] +css_block = '\n\n.person-face-badges {\n position: absolute;\n z-index: 3;\n top: 8px;\n left: 8px;\n right: 8px;\n display: flex;\n flex-wrap: wrap;\n gap: 5px;\n pointer-events: none;\n}\n.person-face-badge {\n padding: 4px 7px;\n border-radius: 999px;\n box-shadow: 0 2px 7px rgba(15, 23, 42, 0.18);\n font-size: 0.68rem;\n font-weight: 800;\n}\n.prototype-badge { background: #dcfae6; color: #067647; }\n.difficult-badge { background: #fef0c7; color: #b54708; }\n.noise-badge { background: #f4ebff; color: #6941c6; }\n.cluster-badge { background: #eff8ff; color: #175cd3; }\n.reference-info { margin-top: 8px; padding: 8px; border-radius: 10px; font-size: 0.76rem; line-height: 1.4; }\n.prototype-info { background: #ecfdf3; color: #067647; }\n.difficult-info { background: #fffaeb; color: #b54708; }\n.recognition-index-panel { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-muted); }\n.index-status { padding: 4px 9px; border-radius: 999px; font-size: 0.76rem; font-weight: 800; }\n.index-status.dirty { background: #fef0c7; color: #b54708; }\n.index-status.ready { background: #dcfae6; color: #067647; }\n.index-state-details { color: var(--muted); font-size: 0.82rem; }\n.person-face-filters { display: flex; gap: 8px; margin-bottom: 16px; overflow-x: auto; }\n.person-face-filter { flex: 0 0 auto; min-height: 36px; padding: 7px 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--text); cursor: pointer; font: inherit; font-size: 0.8rem; font-weight: 700; }\n.person-face-filter.active { border-color: var(--primary); background: var(--primary); color: white; }\n' +if ".person-face-badges {" not in css: + css += css_block +js_path.write_text(js, encoding="utf-8") +css_path.write_text(css, encoding="utf-8") +print("Patch galleria prototype applicata") diff --git a/tools/prototype_experiment.py b/tools/prototype_experiment.py new file mode 100755 index 0000000..38b2f01 --- /dev/null +++ b/tools/prototype_experiment.py @@ -0,0 +1,1079 @@ +#!/usr/bin/env python3 + +import argparse +import json +import sqlite3 +from pathlib import Path + +import numpy as np + + +EMBEDDING_DIM = 128 +EMBEDDING_BYTES = EMBEDDING_DIM * 4 + + +def parse_arguments(): + parser = argparse.ArgumentParser( + description=( + "Confronta strategie di selezione " + "dei prototype per una persona." + ) + ) + + parser.add_argument( + "--database", + required=True, + help="Database SQLite", + ) + + parser.add_argument( + "--user", + required=True, + help="Proprietario della libreria", + ) + + parser.add_argument( + "--person", + required=True, + help="Nome della persona", + ) + + parser.add_argument( + "--noise-prototypes", + type=int, + default=2, + help=( + "Numero di noise confermati " + "da aggiungere ai medoid" + ), + ) + + parser.add_argument( + "--greedy-additions", + type=int, + default=4, + help=( + "Numero di prototype aggiuntivi " + "selezionati per guadagno reale" + ), + ) + + parser.add_argument( + "--min-detector-score", + type=float, + default=0.60, + help=( + "Detector score minimo per " + "i prototype noise candidati" + ), + ) + + parser.add_argument( + "--output", + required=True, + help="File JSON di output", + ) + + return parser.parse_args() + + +def normalize_embedding(blob, face_id): + if blob is None: + raise RuntimeError( + f"Embedding mancante: {face_id}" + ) + + if len(blob) != EMBEDDING_BYTES: + raise RuntimeError( + f"Embedding non valido per {face_id}: " + f"{len(blob)} byte" + ) + + vector = np.frombuffer( + blob, + dtype=" best_mean + ): + best_face = candidate + best_mean = mean_similarity + + selected.append({ + **best_face, + "selection_method": + "cluster_medoid", + "medoid_mean_similarity": + best_mean, + }) + + return selected + + +def normalize_quality(score): + return float( + np.clip( + (score - 0.5) / 0.5, + 0.0, + 1.0, + ) + ) + + +def choose_noise_prototypes( + faces, + initial_prototypes, + count, + minimum_detector_score, +): + candidates = [ + face + for face in faces + if ( + face["source_type"] == "noise" + and face["detector_score"] + >= minimum_detector_score + ) + ] + + selected = list(initial_prototypes) + selected_noise = [] + rankings = [] + + for selection_index in range(count): + scored = [] + + for candidate in candidates: + if any( + item["face_id"] + == candidate["face_id"] + for item in selected + ): + continue + + similarities = [ + cosine( + candidate["embedding"], + prototype["embedding"], + ) + for prototype in selected + ] + + maximum_similarity = ( + max(similarities) + if similarities + else 0.0 + ) + + minimum_distance = ( + 1.0 - maximum_similarity + ) + + quality = normalize_quality( + candidate["detector_score"] + ) + + score = ( + 0.70 * minimum_distance + + 0.30 * quality + ) + + scored.append({ + "face": candidate, + "selection_score": score, + "maximum_similarity_to_selected": + maximum_similarity, + "minimum_distance_to_selected": + minimum_distance, + "quality_component": quality, + }) + + scored.sort( + key=lambda item: ( + item["selection_score"], + item["face"][ + "detector_score" + ], + ), + reverse=True, + ) + + if not scored: + break + + winner = scored[0] + face = winner["face"] + + selected_face = { + **face, + "selection_method": + "confirmed_noise_coverage", + "selection_order": + selection_index + 1, + "selection_score": + winner["selection_score"], + "maximum_similarity_to_selected": + winner[ + "maximum_similarity_to_selected" + ], + } + + selected_noise.append( + selected_face + ) + + selected.append( + selected_face + ) + + rankings.append([ + { + "face_id": + item["face"]["face_id"], + "photo_name": + item["face"]["photo_name"], + "detector_score": + item["face"][ + "detector_score" + ], + "selection_score": + item["selection_score"], + "maximum_similarity_to_selected": + item[ + "maximum_similarity_to_selected" + ], + } + for item in scored[:10] + ]) + + return selected_noise, rankings + + +def evaluate_configuration( + name, + faces, + prototypes, +): + prototype_ids = { + prototype["face_id"] + for prototype in prototypes + } + + results = [] + + for query in faces: + available = [ + prototype + for prototype in prototypes + if ( + prototype["face_id"] + != query["face_id"] + ) + ] + + if not available: + results.append({ + "face_id": query["face_id"], + "photo_name": + query["photo_name"], + "cluster_label": + query["cluster_label"], + "source_type": + query["source_type"], + "detector_score": + query["detector_score"], + "is_prototype": + query["face_id"] + in prototype_ids, + "best_similarity": None, + "best_prototype_face_id": + None, + "top3_mean": None, + }) + + continue + + similarities = sorted( + [ + { + "prototype": + prototype, + "similarity": cosine( + query["embedding"], + prototype["embedding"], + ), + } + for prototype in available + ], + key=lambda item: + item["similarity"], + reverse=True, + ) + + top_count = min( + 3, + len(similarities), + ) + + results.append({ + "face_id": query["face_id"], + "photo_name": + query["photo_name"], + "cluster_label": + query["cluster_label"], + "source_type": + query["source_type"], + "detector_score": + query["detector_score"], + "is_prototype": + query["face_id"] + in prototype_ids, + "best_similarity": + similarities[0][ + "similarity" + ], + "best_prototype_face_id": + similarities[0][ + "prototype" + ]["face_id"], + "top3_mean": float( + np.mean([ + item["similarity"] + for item + in similarities[:top_count] + ]) + ), + }) + + valid_scores = np.array( + [ + item["best_similarity"] + for item in results + if item["best_similarity"] + is not None + ], + dtype=np.float64, + ) + + valid_top3 = np.array( + [ + item["top3_mean"] + for item in results + if item["top3_mean"] + is not None + ], + dtype=np.float64, + ) + + if not len(valid_scores): + raise RuntimeError( + f"Nessun risultato valutabile: {name}" + ) + + summary = { + "configuration": name, + "prototype_count": + len(prototypes), + "query_count": + len(faces), + "best_similarity": { + "minimum": + float(np.min(valid_scores)), + "p10": + float( + np.quantile( + valid_scores, + 0.10, + ) + ), + "median": + float( + np.median(valid_scores) + ), + "mean": + float( + np.mean(valid_scores) + ), + "maximum": + float(np.max(valid_scores)), + }, + "top3_mean": { + "minimum": + float(np.min(valid_top3)), + "p10": + float( + np.quantile( + valid_top3, + 0.10, + ) + ), + "median": + float( + np.median(valid_top3) + ), + "mean": + float( + np.mean(valid_top3) + ), + "maximum": + float(np.max(valid_top3)), + }, + "counts_below": { + "0.30": int( + np.sum(valid_scores < 0.30) + ), + "0.40": int( + np.sum(valid_scores < 0.40) + ), + "0.50": int( + np.sum(valid_scores < 0.50) + ), + "0.60": int( + np.sum(valid_scores < 0.60) + ), + }, + } + + return { + "summary": summary, + "queries": results, + } + + + +def greedy_metric_tuple( + evaluation, + candidate_detector_score, +): + """ + Restituisce una tupla confrontabile. + + Python seleziona la tupla maggiore. + Il numero sotto 0.50 viene negato, + perché un numero minore è migliore. + """ + summary = evaluation["summary"] + + best = summary["best_similarity"] + top3 = summary["top3_mean"] + below = summary["counts_below"] + + return ( + -int(below["0.50"]), + float(best["minimum"]), + float(best["p10"]), + float(top3["p10"]), + float(best["mean"]), + float(top3["mean"]), + float(candidate_detector_score), + ) + + +def metric_snapshot(evaluation): + summary = evaluation["summary"] + + best = summary["best_similarity"] + top3 = summary["top3_mean"] + below = summary["counts_below"] + + return { + "prototype_count": + int(summary["prototype_count"]), + "below_0.50": + int(below["0.50"]), + "below_0.60": + int(below["0.60"]), + "best_min": + float(best["minimum"]), + "best_p10": + float(best["p10"]), + "best_median": + float(best["median"]), + "best_mean": + float(best["mean"]), + "top3_min": + float(top3["minimum"]), + "top3_p10": + float(top3["p10"]), + "top3_mean": + float(top3["mean"]), + } + + +def metric_delta(before, after): + return { + "below_0.50": + after["below_0.50"] + - before["below_0.50"], + "below_0.60": + after["below_0.60"] + - before["below_0.60"], + "best_min": + after["best_min"] + - before["best_min"], + "best_p10": + after["best_p10"] + - before["best_p10"], + "best_mean": + after["best_mean"] + - before["best_mean"], + "top3_p10": + after["top3_p10"] + - before["top3_p10"], + "top3_mean": + after["top3_mean"] + - before["top3_mean"], + } + + +def choose_greedy_prototypes( + faces, + initial_prototypes, + additions, +): + """ + Forward selection leave-one-out. + + A ogni iterazione: + 1. prova ogni volto non selezionato; + 2. valuta l'intero dataset; + 3. sceglie il candidato con la + migliore tupla di metriche. + """ + selected = [ + { + **prototype, + } + for prototype + in initial_prototypes + ] + + selected_ids = { + prototype["face_id"] + for prototype in selected + } + + baseline_evaluation = ( + evaluate_configuration( + "greedy_baseline", + faces, + selected, + ) + ) + + current_snapshot = metric_snapshot( + baseline_evaluation + ) + + steps = [] + + for step_index in range(additions): + candidates = [] + + for candidate in faces: + if ( + candidate["face_id"] + in selected_ids + ): + continue + + trial_prototypes = ( + selected + + [ + { + **candidate, + "selection_method": + "greedy_gain_candidate", + } + ] + ) + + evaluation = ( + evaluate_configuration( + ( + "greedy_trial_" + + str(step_index + 1) + ), + faces, + trial_prototypes, + ) + ) + + snapshot = metric_snapshot( + evaluation + ) + + candidate_result = { + "face": candidate, + "evaluation": evaluation, + "snapshot": snapshot, + "metric_tuple": + greedy_metric_tuple( + evaluation, + candidate[ + "detector_score" + ], + ), + "delta": metric_delta( + current_snapshot, + snapshot, + ), + } + + candidates.append( + candidate_result + ) + + if not candidates: + break + + candidates.sort( + key=lambda item: + item["metric_tuple"], + reverse=True, + ) + + winner = candidates[0] + winner_face = winner["face"] + + selected_face = { + **winner_face, + "selection_method": + "greedy_leave_one_out_gain", + "selection_order": + step_index + 1, + "metrics_before": + current_snapshot, + "metrics_after": + winner["snapshot"], + "metric_delta": + winner["delta"], + } + + selected.append( + selected_face + ) + + selected_ids.add( + selected_face["face_id"] + ) + + steps.append({ + "step": + step_index + 1, + "selected_face": + simplify_face( + selected_face + ), + "metrics_before": + current_snapshot, + "metrics_after": + winner["snapshot"], + "metric_delta": + winner["delta"], + "top_candidates": [ + { + "face_id": + item["face"][ + "face_id" + ], + "photo_name": + item["face"][ + "photo_name" + ], + "source_type": + item["face"][ + "source_type" + ], + "cluster_label": + item["face"][ + "cluster_label" + ], + "detector_score": + item["face"][ + "detector_score" + ], + "metrics": + item["snapshot"], + "metric_delta": + item["delta"], + } + for item + in candidates[:10] + ], + }) + + current_snapshot = ( + winner["snapshot"] + ) + + return selected, steps + + +def simplify_face(face): + excluded = { + "embedding", + } + + return { + key: value + for key, value in face.items() + if key not in excluded + } + + +def main(): + arguments = parse_arguments() + + database_path = Path( + arguments.database + ).resolve() + + output_path = Path( + arguments.output + ).resolve() + + person, faces = load_person_faces( + database_path, + arguments.user, + arguments.person, + ) + + cluster_faces = [ + face + for face in faces + if face["source_type"] + == "cluster" + ] + + noise_faces = [ + face + for face in faces + if face["source_type"] + == "noise" + ] + + medoids = choose_cluster_medoids( + faces + ) + + if not medoids: + raise RuntimeError( + "Nessun cluster assegnato " + "alla persona" + ) + + selected_noise, noise_rankings = ( + choose_noise_prototypes( + faces, + medoids, + arguments.noise_prototypes, + arguments.min_detector_score, + ) + ) + + mixed_prototypes = ( + medoids + + selected_noise + ) + + ( + greedy_prototypes, + greedy_steps, + ) = choose_greedy_prototypes( + faces, + medoids, + arguments.greedy_additions, + ) + + all_confirmed = [ + { + **face, + "selection_method": + "all_confirmed", + } + for face in faces + ] + + configurations = { + "cluster_medoids": medoids, + "cluster_medoids_plus_noise": + mixed_prototypes, + "greedy_gain": + greedy_prototypes, + "all_confirmed": all_confirmed, + } + + evaluations = {} + + for name, prototypes in ( + configurations.items() + ): + evaluations[name] = ( + evaluate_configuration( + name, + faces, + prototypes, + ) + ) + + result = { + "database": str(database_path), + "person": person, + "dataset": { + "total_confirmed_faces": + len(faces), + "clustered_faces": + len(cluster_faces), + "confirmed_noise_faces": + len(noise_faces), + "cluster_labels": sorted({ + face["cluster_label"] + for face in cluster_faces + }), + }, + "parameters": { + "noise_prototypes": + arguments.noise_prototypes, + "min_detector_score": + arguments.min_detector_score, + }, + "prototype_sets": { + name: [ + simplify_face(face) + for face in prototypes + ] + for name, prototypes + in configurations.items() + }, + "noise_selection_rankings": + noise_rankings, + "greedy_selection_steps": + greedy_steps, + "evaluations": evaluations, + "notes": [ + ( + "Questa prova misura la copertura " + "dei volti confermati della stessa " + "persona." + ), + ( + "Non misura i falsi positivi perché " + "è disponibile una sola persona " + "confermata." + ), + ( + "La selezione non modifica " + "il database." + ), + ], + } + + output_path.parent.mkdir( + parents=True, + exist_ok=True, + ) + + output_path.write_text( + json.dumps( + result, + indent=2, + ensure_ascii=False, + ) + + "\n", + encoding="utf-8", + ) + + print(json.dumps( + { + "person": person["name"], + "total_faces": + len(faces), + "cluster_medoids": + len(medoids), + "selected_noise": + len(selected_noise), + "mixed_prototypes": + len(mixed_prototypes), + "greedy_prototypes": + len(greedy_prototypes), + "greedy_additions": + len(greedy_steps), + "output": + str(output_path), + }, + indent=2, + ensure_ascii=False, + )) + + +if __name__ == "__main__": + main() diff --git a/tools/prototypes/jessica-greedy-5.json b/tools/prototypes/jessica-greedy-5.json new file mode 100644 index 0000000..ce9bfd8 --- /dev/null +++ b/tools/prototypes/jessica-greedy-5.json @@ -0,0 +1,2446 @@ +{ + "database": "/home/nvme/dockerdata/prove/s131js/api_v1/database.sqlite", + "person": { + "id": "9b4843ab-6e5f-4ea9-9c3d-f1d545ed603b", + "user": "Fabio", + "name": "Jessica" + }, + "dataset": { + "total_confirmed_faces": 21, + "clustered_faces": 10, + "confirmed_noise_faces": 11, + "cluster_labels": [ + 2, + 3, + 4 + ] + }, + "parameters": { + "noise_prototypes": 2, + "min_detector_score": 0.6 + }, + "prototype_sets": { + "cluster_medoids": [ + { + "face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "photo_id": "f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f", + "photo_name": "IMG_0145.JPG", + "face_index": 0, + "detector_score": 0.80078125, + "cluster_id": "abca0a85-9553-473f-933b-af88389920f7", + "cluster_label": 2, + "source_type": "cluster", + "rotation": 90, + "selection_method": "cluster_medoid", + "medoid_mean_similarity": 0.8703833222389221 + }, + { + "face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "photo_id": "ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839", + "photo_name": "IMG_0141.JPG", + "face_index": 0, + "detector_score": 0.764160156, + "cluster_id": "c7d72125-2f55-4b78-ad08-519c14ee619a", + "cluster_label": 3, + "source_type": "cluster", + "rotation": 0, + "selection_method": "cluster_medoid", + "medoid_mean_similarity": 0.8580909073352814 + }, + { + "face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "photo_id": "c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f", + "photo_name": "IMG_0193.JPG", + "face_index": 0, + "detector_score": 0.905273438, + "cluster_id": "b5fb6bd6-6c60-494a-9a88-caccf29225a6", + "cluster_label": 4, + "source_type": "cluster", + "rotation": 0, + "selection_method": "cluster_medoid", + "medoid_mean_similarity": 0.8970536589622498 + } + ], + "cluster_medoids_plus_noise": [ + { + "face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "photo_id": "f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f", + "photo_name": "IMG_0145.JPG", + "face_index": 0, + "detector_score": 0.80078125, + "cluster_id": "abca0a85-9553-473f-933b-af88389920f7", + "cluster_label": 2, + "source_type": "cluster", + "rotation": 90, + "selection_method": "cluster_medoid", + "medoid_mean_similarity": 0.8703833222389221 + }, + { + "face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "photo_id": "ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839", + "photo_name": "IMG_0141.JPG", + "face_index": 0, + "detector_score": 0.764160156, + "cluster_id": "c7d72125-2f55-4b78-ad08-519c14ee619a", + "cluster_label": 3, + "source_type": "cluster", + "rotation": 0, + "selection_method": "cluster_medoid", + "medoid_mean_similarity": 0.8580909073352814 + }, + { + "face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "photo_id": "c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f", + "photo_name": "IMG_0193.JPG", + "face_index": 0, + "detector_score": 0.905273438, + "cluster_id": "b5fb6bd6-6c60-494a-9a88-caccf29225a6", + "cluster_label": 4, + "source_type": "cluster", + "rotation": 0, + "selection_method": "cluster_medoid", + "medoid_mean_similarity": 0.8970536589622498 + }, + { + "face_id": "face:4065f86b9af59235a5727abaaccb54a9a91fa585b464da2c592db5ed300d8836:0", + "photo_id": "4065f86b9af59235a5727abaaccb54a9a91fa585b464da2c592db5ed300d8836", + "photo_name": "IMG_0183.JPG", + "face_index": 0, + "detector_score": 0.680664062, + "cluster_id": null, + "cluster_label": null, + "source_type": "noise", + "rotation": 90, + "selection_method": "confirmed_noise_coverage", + "selection_order": 1, + "selection_score": 0.5041585174183151, + "maximum_similarity_to_selected": 0.4346284568309784 + }, + { + "face_id": "face:2454c04dd9067d2b5288dcca99c5c3eac85660779a97d6165a08ca7c37481070:0", + "photo_id": "2454c04dd9067d2b5288dcca99c5c3eac85660779a97d6165a08ca7c37481070", + "photo_name": "IMG_0214.JPG", + "face_index": 0, + "detector_score": 0.882324219, + "cluster_id": null, + "cluster_label": null, + "source_type": "noise", + "rotation": 90, + "selection_method": "confirmed_noise_coverage", + "selection_order": 2, + "selection_score": 0.4766559065888275, + "maximum_similarity_to_selected": 0.6467694640159607 + } + ], + "greedy_gain": [ + { + "face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "photo_id": "f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f", + "photo_name": "IMG_0145.JPG", + "face_index": 0, + "detector_score": 0.80078125, + "cluster_id": "abca0a85-9553-473f-933b-af88389920f7", + "cluster_label": 2, + "source_type": "cluster", + "rotation": 90, + "selection_method": "cluster_medoid", + "medoid_mean_similarity": 0.8703833222389221 + }, + { + "face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "photo_id": "ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839", + "photo_name": "IMG_0141.JPG", + "face_index": 0, + "detector_score": 0.764160156, + "cluster_id": "c7d72125-2f55-4b78-ad08-519c14ee619a", + "cluster_label": 3, + "source_type": "cluster", + "rotation": 0, + "selection_method": "cluster_medoid", + "medoid_mean_similarity": 0.8580909073352814 + }, + { + "face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "photo_id": "c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f", + "photo_name": "IMG_0193.JPG", + "face_index": 0, + "detector_score": 0.905273438, + "cluster_id": "b5fb6bd6-6c60-494a-9a88-caccf29225a6", + "cluster_label": 4, + "source_type": "cluster", + "rotation": 0, + "selection_method": "cluster_medoid", + "medoid_mean_similarity": 0.8970536589622498 + }, + { + "face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "photo_id": "c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6", + "photo_name": "IMG_0143.JPG", + "face_index": 0, + "detector_score": 0.860839844, + "cluster_id": "c7d72125-2f55-4b78-ad08-519c14ee619a", + "cluster_label": 3, + "source_type": "cluster", + "rotation": 0, + "selection_method": "greedy_leave_one_out_gain", + "selection_order": 1, + "metrics_before": { + "prototype_count": 3, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4346284568309784, + "best_p10": 0.6467694640159607, + "best_median": 0.8265709280967712, + "best_mean": 0.7850906749566396, + "top3_min": 0.40839412808418274, + "top3_p10": 0.6083214084307352, + "top3_mean": 0.7489666281238434 + }, + "metrics_after": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6467694640159607, + "best_median": 0.8329968452453613, + "best_mean": 0.7916993952932811, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6251600384712219, + "top3_mean": 0.7625383668475679 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.03179958462715149, + "best_p10": 0.0, + "best_mean": 0.006608720336641505, + "top3_p10": 0.01683863004048669, + "top3_mean": 0.013571738723724525 + } + }, + { + "face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "photo_id": "5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e", + "photo_name": "IMG_0146.JPG", + "face_index": 0, + "detector_score": 0.779296875, + "cluster_id": "abca0a85-9553-473f-933b-af88389920f7", + "cluster_label": 2, + "source_type": "cluster", + "rotation": 90, + "selection_method": "greedy_leave_one_out_gain", + "selection_order": 2, + "metrics_before": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6467694640159607, + "best_median": 0.8329968452453613, + "best_mean": 0.7916993952932811, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6251600384712219, + "top3_mean": 0.7625383668475679 + }, + "metrics_after": { + "prototype_count": 5, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.7093561887741089, + "best_median": 0.8368068933486938, + "best_mean": 0.8004826903343201, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6665714184443156, + "top3_mean": 0.7700458320360336 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0625867247581482, + "best_mean": 0.008783295041038919, + "top3_p10": 0.04141137997309363, + "top3_mean": 0.0075074651884656785 + } + } + ], + "all_confirmed": [ + { + "face_id": "face:4025ea19107cf40d092576e4aaa122bdfb027dc90592a50dd6fa8131838dae69:0", + "photo_id": "4025ea19107cf40d092576e4aaa122bdfb027dc90592a50dd6fa8131838dae69", + "photo_name": "IMG_0153.JPG", + "face_index": 0, + "detector_score": 0.861328125, + "cluster_id": null, + "cluster_label": null, + "source_type": "noise", + "rotation": 270, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:ddab26bedbe3287cb210d46a2397336f879655c99224f90f8da1646ee111fee8:0", + "photo_id": "ddab26bedbe3287cb210d46a2397336f879655c99224f90f8da1646ee111fee8", + "photo_name": "IMG_0167.JPG", + "face_index": 0, + "detector_score": 0.866699219, + "cluster_id": null, + "cluster_label": null, + "source_type": "noise", + "rotation": 90, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:0bd35f8c46fa2ec4979d977d13bab6b8e0dc18365a0f25a7d87d81c5336c0378:0", + "photo_id": "0bd35f8c46fa2ec4979d977d13bab6b8e0dc18365a0f25a7d87d81c5336c0378", + "photo_name": "IMG_0182.JPG", + "face_index": 0, + "detector_score": 0.680175781, + "cluster_id": null, + "cluster_label": null, + "source_type": "noise", + "rotation": 90, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:4065f86b9af59235a5727abaaccb54a9a91fa585b464da2c592db5ed300d8836:0", + "photo_id": "4065f86b9af59235a5727abaaccb54a9a91fa585b464da2c592db5ed300d8836", + "photo_name": "IMG_0183.JPG", + "face_index": 0, + "detector_score": 0.680664062, + "cluster_id": null, + "cluster_label": null, + "source_type": "noise", + "rotation": 90, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:77b822134129d751070d73441dda90996dab21186c50be2ce79064e22bba94bd:0", + "photo_id": "77b822134129d751070d73441dda90996dab21186c50be2ce79064e22bba94bd", + "photo_name": "IMG_0203.JPG", + "face_index": 0, + "detector_score": 0.778808594, + "cluster_id": null, + "cluster_label": null, + "source_type": "noise", + "rotation": 0, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:3041f044b86b8888937659a1c691db34f0c8b52b6dec6a7db023c18e9cd24722:0", + "photo_id": "3041f044b86b8888937659a1c691db34f0c8b52b6dec6a7db023c18e9cd24722", + "photo_name": "IMG_0209.JPG", + "face_index": 0, + "detector_score": 0.650878906, + "cluster_id": null, + "cluster_label": null, + "source_type": "noise", + "rotation": 0, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:2454c04dd9067d2b5288dcca99c5c3eac85660779a97d6165a08ca7c37481070:0", + "photo_id": "2454c04dd9067d2b5288dcca99c5c3eac85660779a97d6165a08ca7c37481070", + "photo_name": "IMG_0214.JPG", + "face_index": 0, + "detector_score": 0.882324219, + "cluster_id": null, + "cluster_label": null, + "source_type": "noise", + "rotation": 90, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:a99f57f0bea2cb9b6caf3d0e60c3719e379d3d5bc2bbe37d1044c764214dd0b9:0", + "photo_id": "a99f57f0bea2cb9b6caf3d0e60c3719e379d3d5bc2bbe37d1044c764214dd0b9", + "photo_name": "IMG_0216.JPG", + "face_index": 0, + "detector_score": 0.890136719, + "cluster_id": null, + "cluster_label": null, + "source_type": "noise", + "rotation": 0, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:915492fbf9fc1769985e4f5684d42728b5ee63092b148b887c6d07d1fb66fe8f:0", + "photo_id": "915492fbf9fc1769985e4f5684d42728b5ee63092b148b887c6d07d1fb66fe8f", + "photo_name": "IMG_0217.JPG", + "face_index": 0, + "detector_score": 0.919433594, + "cluster_id": null, + "cluster_label": null, + "source_type": "noise", + "rotation": 0, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:1a4313d5086018efcd689dbca06040d99a75f7b331798b0fa2ced0d25dea95b7:0", + "photo_id": "1a4313d5086018efcd689dbca06040d99a75f7b331798b0fa2ced0d25dea95b7", + "photo_name": "IMG_0232.JPG", + "face_index": 0, + "detector_score": 0.807128906, + "cluster_id": null, + "cluster_label": null, + "source_type": "noise", + "rotation": 90, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:e9549aa2bb21ad70ff35ad1626cf15f6cb62a321e25e3e7ab832bd9e7430e186:0", + "photo_id": "e9549aa2bb21ad70ff35ad1626cf15f6cb62a321e25e3e7ab832bd9e7430e186", + "photo_name": "IMG_0245.JPG", + "face_index": 0, + "detector_score": 0.744140625, + "cluster_id": null, + "cluster_label": null, + "source_type": "noise", + "rotation": 90, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:10ca45bc5aab42b5ba08da7d6a15d7f942478ca5e052f89054097c93d3dd6134:0", + "photo_id": "10ca45bc5aab42b5ba08da7d6a15d7f942478ca5e052f89054097c93d3dd6134", + "photo_name": "IMG_0135.JPG", + "face_index": 0, + "detector_score": 0.776367188, + "cluster_id": "abca0a85-9553-473f-933b-af88389920f7", + "cluster_label": 2, + "source_type": "cluster", + "rotation": 90, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "photo_id": "f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f", + "photo_name": "IMG_0145.JPG", + "face_index": 0, + "detector_score": 0.80078125, + "cluster_id": "abca0a85-9553-473f-933b-af88389920f7", + "cluster_label": 2, + "source_type": "cluster", + "rotation": 90, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "photo_id": "5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e", + "photo_name": "IMG_0146.JPG", + "face_index": 0, + "detector_score": 0.779296875, + "cluster_id": "abca0a85-9553-473f-933b-af88389920f7", + "cluster_label": 2, + "source_type": "cluster", + "rotation": 90, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:dd715de9995fd11a23fee1df7c1561ea45bd215e765e9e93b2efc3f2ca59c39c:0", + "photo_id": "dd715de9995fd11a23fee1df7c1561ea45bd215e765e9e93b2efc3f2ca59c39c", + "photo_name": "IMG_0147.JPG", + "face_index": 0, + "detector_score": 0.802734375, + "cluster_id": "abca0a85-9553-473f-933b-af88389920f7", + "cluster_label": 2, + "source_type": "cluster", + "rotation": 90, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "photo_id": "ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839", + "photo_name": "IMG_0141.JPG", + "face_index": 0, + "detector_score": 0.764160156, + "cluster_id": "c7d72125-2f55-4b78-ad08-519c14ee619a", + "cluster_label": 3, + "source_type": "cluster", + "rotation": 0, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "photo_id": "c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6", + "photo_name": "IMG_0143.JPG", + "face_index": 0, + "detector_score": 0.860839844, + "cluster_id": "c7d72125-2f55-4b78-ad08-519c14ee619a", + "cluster_label": 3, + "source_type": "cluster", + "rotation": 0, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:8e1a2d2abbd36803ec8670f56869ae723c4c8c8fcd5e634ada9f988c942df1b8:0", + "photo_id": "8e1a2d2abbd36803ec8670f56869ae723c4c8c8fcd5e634ada9f988c942df1b8", + "photo_name": "IMG_0148.JPG", + "face_index": 0, + "detector_score": 0.845214844, + "cluster_id": "c7d72125-2f55-4b78-ad08-519c14ee619a", + "cluster_label": 3, + "source_type": "cluster", + "rotation": 90, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:e30a1427708cfc8d63a66d369781b15ac6a07b43e13550b1a7625003914503ed:0", + "photo_id": "e30a1427708cfc8d63a66d369781b15ac6a07b43e13550b1a7625003914503ed", + "photo_name": "IMG_0190.JPG", + "face_index": 0, + "detector_score": 0.8671875, + "cluster_id": "b5fb6bd6-6c60-494a-9a88-caccf29225a6", + "cluster_label": 4, + "source_type": "cluster", + "rotation": 0, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "photo_id": "c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f", + "photo_name": "IMG_0193.JPG", + "face_index": 0, + "detector_score": 0.905273438, + "cluster_id": "b5fb6bd6-6c60-494a-9a88-caccf29225a6", + "cluster_label": 4, + "source_type": "cluster", + "rotation": 0, + "selection_method": "all_confirmed" + }, + { + "face_id": "face:b1f9d25d6d7217c01a3a67f59176d54ce73b1bb626a687d1447a5354e5cad020:0", + "photo_id": "b1f9d25d6d7217c01a3a67f59176d54ce73b1bb626a687d1447a5354e5cad020", + "photo_name": "IMG_0244.JPG", + "face_index": 0, + "detector_score": 0.874511719, + "cluster_id": "b5fb6bd6-6c60-494a-9a88-caccf29225a6", + "cluster_label": 4, + "source_type": "cluster", + "rotation": 90, + "selection_method": "all_confirmed" + } + ] + }, + "noise_selection_rankings": [ + [ + { + "face_id": "face:4065f86b9af59235a5727abaaccb54a9a91fa585b464da2c592db5ed300d8836:0", + "photo_name": "IMG_0183.JPG", + "detector_score": 0.680664062, + "selection_score": 0.5041585174183151, + "maximum_similarity_to_selected": 0.4346284568309784 + }, + { + "face_id": "face:2454c04dd9067d2b5288dcca99c5c3eac85660779a97d6165a08ca7c37481070:0", + "photo_name": "IMG_0214.JPG", + "detector_score": 0.882324219, + "selection_score": 0.4766559065888275, + "maximum_similarity_to_selected": 0.6467694640159607 + }, + { + "face_id": "face:1a4313d5086018efcd689dbca06040d99a75f7b331798b0fa2ced0d25dea95b7:0", + "photo_name": "IMG_0232.JPG", + "detector_score": 0.807128906, + "selection_score": 0.46311328992148737, + "maximum_similarity_to_selected": 0.6016629338264465 + }, + { + "face_id": "face:a99f57f0bea2cb9b6caf3d0e60c3719e379d3d5bc2bbe37d1044c764214dd0b9:0", + "photo_name": "IMG_0216.JPG", + "detector_score": 0.890136719, + "selection_score": 0.4374496282647003, + "maximum_similarity_to_selected": 0.7094748616218567 + }, + { + "face_id": "face:915492fbf9fc1769985e4f5684d42728b5ee63092b148b887c6d07d1fb66fe8f:0", + "photo_name": "IMG_0217.JPG", + "detector_score": 0.919433594, + "selection_score": 0.3658953310559143, + "maximum_similarity_to_selected": 0.8368068933486938 + }, + { + "face_id": "face:e9549aa2bb21ad70ff35ad1626cf15f6cb62a321e25e3e7ab832bd9e7430e186:0", + "photo_name": "IMG_0245.JPG", + "detector_score": 0.744140625, + "selection_score": 0.362738823890686, + "maximum_similarity_to_selected": 0.6910650730133057 + }, + { + "face_id": "face:4025ea19107cf40d092576e4aaa122bdfb027dc90592a50dd6fa8131838dae69:0", + "photo_name": "IMG_0153.JPG", + "detector_score": 0.861328125, + "selection_score": 0.3480863869190216, + "maximum_similarity_to_selected": 0.8124435544013977 + }, + { + "face_id": "face:77b822134129d751070d73441dda90996dab21186c50be2ce79064e22bba94bd:0", + "photo_name": "IMG_0203.JPG", + "detector_score": 0.778808594, + "selection_score": 0.3441595317433227, + "maximum_similarity_to_selected": 0.7473223209381104 + }, + { + "face_id": "face:ddab26bedbe3287cb210d46a2397336f879655c99224f90f8da1646ee111fee8:0", + "photo_name": "IMG_0167.JPG", + "detector_score": 0.866699219, + "selection_score": 0.3414198817322601, + "maximum_similarity_to_selected": 0.8265709280967712 + }, + { + "face_id": "face:0bd35f8c46fa2ec4979d977d13bab6b8e0dc18365a0f25a7d87d81c5336c0378:0", + "photo_name": "IMG_0182.JPG", + "detector_score": 0.680175781, + "selection_score": 0.29762958273192747, + "maximum_similarity_to_selected": 0.7292512655258179 + } + ], + [ + { + "face_id": "face:2454c04dd9067d2b5288dcca99c5c3eac85660779a97d6165a08ca7c37481070:0", + "photo_name": "IMG_0214.JPG", + "detector_score": 0.882324219, + "selection_score": 0.4766559065888275, + "maximum_similarity_to_selected": 0.6467694640159607 + }, + { + "face_id": "face:1a4313d5086018efcd689dbca06040d99a75f7b331798b0fa2ced0d25dea95b7:0", + "photo_name": "IMG_0232.JPG", + "detector_score": 0.807128906, + "selection_score": 0.46311328992148737, + "maximum_similarity_to_selected": 0.6016629338264465 + }, + { + "face_id": "face:a99f57f0bea2cb9b6caf3d0e60c3719e379d3d5bc2bbe37d1044c764214dd0b9:0", + "photo_name": "IMG_0216.JPG", + "detector_score": 0.890136719, + "selection_score": 0.4374496282647003, + "maximum_similarity_to_selected": 0.7094748616218567 + }, + { + "face_id": "face:915492fbf9fc1769985e4f5684d42728b5ee63092b148b887c6d07d1fb66fe8f:0", + "photo_name": "IMG_0217.JPG", + "detector_score": 0.919433594, + "selection_score": 0.3658953310559143, + "maximum_similarity_to_selected": 0.8368068933486938 + }, + { + "face_id": "face:e9549aa2bb21ad70ff35ad1626cf15f6cb62a321e25e3e7ab832bd9e7430e186:0", + "photo_name": "IMG_0245.JPG", + "detector_score": 0.744140625, + "selection_score": 0.362738823890686, + "maximum_similarity_to_selected": 0.6910650730133057 + }, + { + "face_id": "face:4025ea19107cf40d092576e4aaa122bdfb027dc90592a50dd6fa8131838dae69:0", + "photo_name": "IMG_0153.JPG", + "detector_score": 0.861328125, + "selection_score": 0.3480863869190216, + "maximum_similarity_to_selected": 0.8124435544013977 + }, + { + "face_id": "face:77b822134129d751070d73441dda90996dab21186c50be2ce79064e22bba94bd:0", + "photo_name": "IMG_0203.JPG", + "detector_score": 0.778808594, + "selection_score": 0.3441595317433227, + "maximum_similarity_to_selected": 0.7473223209381104 + }, + { + "face_id": "face:ddab26bedbe3287cb210d46a2397336f879655c99224f90f8da1646ee111fee8:0", + "photo_name": "IMG_0167.JPG", + "detector_score": 0.866699219, + "selection_score": 0.3414198817322601, + "maximum_similarity_to_selected": 0.8265709280967712 + }, + { + "face_id": "face:0bd35f8c46fa2ec4979d977d13bab6b8e0dc18365a0f25a7d87d81c5336c0378:0", + "photo_name": "IMG_0182.JPG", + "detector_score": 0.680175781, + "selection_score": 0.29762958273192747, + "maximum_similarity_to_selected": 0.7292512655258179 + }, + { + "face_id": "face:3041f044b86b8888937659a1c691db34f0c8b52b6dec6a7db023c18e9cd24722:0", + "photo_name": "IMG_0209.JPG", + "detector_score": 0.650878906, + "selection_score": 0.2014094291617523, + "maximum_similarity_to_selected": 0.8415970206260681 + } + ] + ], + "greedy_selection_steps": [ + { + "step": 1, + "selected_face": { + "face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "photo_id": "c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6", + "photo_name": "IMG_0143.JPG", + "face_index": 0, + "detector_score": 0.860839844, + "cluster_id": "c7d72125-2f55-4b78-ad08-519c14ee619a", + "cluster_label": 3, + "source_type": "cluster", + "rotation": 0, + "selection_method": "greedy_leave_one_out_gain", + "selection_order": 1, + "metrics_before": { + "prototype_count": 3, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4346284568309784, + "best_p10": 0.6467694640159607, + "best_median": 0.8265709280967712, + "best_mean": 0.7850906749566396, + "top3_min": 0.40839412808418274, + "top3_p10": 0.6083214084307352, + "top3_mean": 0.7489666281238434 + }, + "metrics_after": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6467694640159607, + "best_median": 0.8329968452453613, + "best_mean": 0.7916993952932811, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6251600384712219, + "top3_mean": 0.7625383668475679 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.03179958462715149, + "best_p10": 0.0, + "best_mean": 0.006608720336641505, + "top3_p10": 0.01683863004048669, + "top3_mean": 0.013571738723724525 + } + }, + "metrics_before": { + "prototype_count": 3, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4346284568309784, + "best_p10": 0.6467694640159607, + "best_median": 0.8265709280967712, + "best_mean": 0.7850906749566396, + "top3_min": 0.40839412808418274, + "top3_p10": 0.6083214084307352, + "top3_mean": 0.7489666281238434 + }, + "metrics_after": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6467694640159607, + "best_median": 0.8329968452453613, + "best_mean": 0.7916993952932811, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6251600384712219, + "top3_mean": 0.7625383668475679 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.03179958462715149, + "best_p10": 0.0, + "best_mean": 0.006608720336641505, + "top3_p10": 0.01683863004048669, + "top3_mean": 0.013571738723724525 + }, + "top_candidates": [ + { + "face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "photo_name": "IMG_0143.JPG", + "source_type": "cluster", + "cluster_label": 3, + "detector_score": 0.860839844, + "metrics": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6467694640159607, + "best_median": 0.8329968452453613, + "best_mean": 0.7916993952932811, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6251600384712219, + "top3_mean": 0.7625383668475679 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.03179958462715149, + "best_p10": 0.0, + "best_mean": 0.006608720336641505, + "top3_p10": 0.01683863004048669, + "top3_mean": 0.013571738723724525 + } + }, + { + "face_id": "face:3041f044b86b8888937659a1c691db34f0c8b52b6dec6a7db023c18e9cd24722:0", + "photo_name": "IMG_0209.JPG", + "source_type": "noise", + "cluster_label": null, + "detector_score": 0.650878906, + "metrics": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.45767277479171753, + "best_p10": 0.6467694640159607, + "best_median": 0.8329968452453613, + "best_mean": 0.7874947275434222, + "top3_min": 0.4311859409014384, + "top3_p10": 0.6090601086616516, + "top3_mean": 0.7554428265208288 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.023044317960739136, + "best_p10": 0.0, + "best_mean": 0.002404052586782557, + "top3_p10": 0.0007387002309163782, + "top3_mean": 0.006476198396985433 + } + }, + { + "face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "photo_name": "IMG_0146.JPG", + "source_type": "cluster", + "cluster_label": 2, + "detector_score": 0.779296875, + "metrics": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4346284568309784, + "best_p10": 0.6910650730133057, + "best_median": 0.8311539888381958, + "best_mean": 0.7938739699976785, + "top3_min": 0.40839412808418274, + "top3_p10": 0.6665714184443156, + "top3_mean": 0.7590488569130973 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.04429560899734497, + "best_mean": 0.008783295041038808, + "top3_p10": 0.05825001001358032, + "top3_mean": 0.01008222878925391 + } + }, + { + "face_id": "face:e30a1427708cfc8d63a66d369781b15ac6a07b43e13550b1a7625003914503ed:0", + "photo_name": "IMG_0190.JPG", + "source_type": "cluster", + "cluster_label": 4, + "detector_score": 0.8671875, + "metrics": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4346284568309784, + "best_p10": 0.6704736351966858, + "best_median": 0.8265709280967712, + "best_mean": 0.7908432157266707, + "top3_min": 0.40839412808418274, + "top3_p10": 0.6525195837020874, + "top3_mean": 0.761086531574764 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.023704171180725098, + "best_mean": 0.005752540770031067, + "top3_p10": 0.04419817527135217, + "top3_mean": 0.012119903450920622 + } + }, + { + "face_id": "face:10ca45bc5aab42b5ba08da7d6a15d7f942478ca5e052f89054097c93d3dd6134:0", + "photo_name": "IMG_0135.JPG", + "source_type": "cluster", + "cluster_label": 2, + "detector_score": 0.776367188, + "metrics": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4346284568309784, + "best_p10": 0.6589575409889221, + "best_median": 0.8265709280967712, + "best_mean": 0.7879999719914936, + "top3_min": 0.42197684446970624, + "top3_p10": 0.6292567451794943, + "top3_mean": 0.7591723865932889 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.012188076972961426, + "best_mean": 0.002909297034853986, + "top3_p10": 0.020935336748759026, + "top3_mean": 0.010205758469445492 + } + }, + { + "face_id": "face:dd715de9995fd11a23fee1df7c1561ea45bd215e765e9e93b2efc3f2ca59c39c:0", + "photo_name": "IMG_0147.JPG", + "source_type": "cluster", + "cluster_label": 2, + "detector_score": 0.802734375, + "metrics": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4346284568309784, + "best_p10": 0.6467694640159607, + "best_median": 0.8368068933486938, + "best_mean": 0.7914983672755105, + "top3_min": 0.4086805284023285, + "top3_p10": 0.6316222151120504, + "top3_mean": 0.7622613457460251 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0, + "best_mean": 0.006407692318870839, + "top3_p10": 0.023300806681315178, + "top3_mean": 0.013294717622181706 + } + }, + { + "face_id": "face:ddab26bedbe3287cb210d46a2397336f879655c99224f90f8da1646ee111fee8:0", + "photo_name": "IMG_0167.JPG", + "source_type": "noise", + "cluster_label": null, + "detector_score": 0.866699219, + "metrics": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4346284568309784, + "best_p10": 0.6467694640159607, + "best_median": 0.8265709280967712, + "best_mean": 0.7872486270609356, + "top3_min": 0.40919429063796997, + "top3_p10": 0.6184323827425638, + "top3_mean": 0.759945483434768 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0, + "best_mean": 0.0021579521042959238, + "top3_p10": 0.010110974311828613, + "top3_mean": 0.010978855310924573 + } + }, + { + "face_id": "face:4025ea19107cf40d092576e4aaa122bdfb027dc90592a50dd6fa8131838dae69:0", + "photo_name": "IMG_0153.JPG", + "source_type": "noise", + "cluster_label": null, + "detector_score": 0.861328125, + "metrics": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4346284568309784, + "best_p10": 0.6467694640159607, + "best_median": 0.8265709280967712, + "best_mean": 0.785141986040842, + "top3_min": 0.41215941309928894, + "top3_p10": 0.6159278949101766, + "top3_mean": 0.752336934918449 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0, + "best_mean": 5.131108420231012e-05, + "top3_p10": 0.007606486479441399, + "top3_mean": 0.003370306794605593 + } + }, + { + "face_id": "face:8e1a2d2abbd36803ec8670f56869ae723c4c8c8fcd5e634ada9f988c942df1b8:0", + "photo_name": "IMG_0148.JPG", + "source_type": "cluster", + "cluster_label": 3, + "detector_score": 0.845214844, + "metrics": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4346284568309784, + "best_p10": 0.6467694640159607, + "best_median": 0.8329968452453613, + "best_mean": 0.7871219529992058, + "top3_min": 0.4208176831404368, + "top3_p10": 0.6143219073613485, + "top3_mean": 0.7533405011608488 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0, + "best_mean": 0.002031278042566198, + "top3_p10": 0.006000498930613274, + "top3_mean": 0.004373873037005427 + } + }, + { + "face_id": "face:0bd35f8c46fa2ec4979d977d13bab6b8e0dc18365a0f25a7d87d81c5336c0378:0", + "photo_name": "IMG_0182.JPG", + "source_type": "noise", + "cluster_label": null, + "detector_score": 0.680175781, + "metrics": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4346284568309784, + "best_p10": 0.6467694640159607, + "best_median": 0.8265709280967712, + "best_mean": 0.7871495215665727, + "top3_min": 0.4171876112620036, + "top3_p10": 0.61353600025177, + "top3_mean": 0.748203038223206 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0, + "best_mean": 0.0020588466099330516, + "top3_p10": 0.005214591821034786, + "top3_mean": -0.000763589900637407 + } + } + ] + }, + { + "step": 2, + "selected_face": { + "face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "photo_id": "5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e", + "photo_name": "IMG_0146.JPG", + "face_index": 0, + "detector_score": 0.779296875, + "cluster_id": "abca0a85-9553-473f-933b-af88389920f7", + "cluster_label": 2, + "source_type": "cluster", + "rotation": 90, + "selection_method": "greedy_leave_one_out_gain", + "selection_order": 2, + "metrics_before": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6467694640159607, + "best_median": 0.8329968452453613, + "best_mean": 0.7916993952932811, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6251600384712219, + "top3_mean": 0.7625383668475679 + }, + "metrics_after": { + "prototype_count": 5, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.7093561887741089, + "best_median": 0.8368068933486938, + "best_mean": 0.8004826903343201, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6665714184443156, + "top3_mean": 0.7700458320360336 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0625867247581482, + "best_mean": 0.008783295041038919, + "top3_p10": 0.04141137997309363, + "top3_mean": 0.0075074651884656785 + } + }, + "metrics_before": { + "prototype_count": 4, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6467694640159607, + "best_median": 0.8329968452453613, + "best_mean": 0.7916993952932811, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6251600384712219, + "top3_mean": 0.7625383668475679 + }, + "metrics_after": { + "prototype_count": 5, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.7093561887741089, + "best_median": 0.8368068933486938, + "best_mean": 0.8004826903343201, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6665714184443156, + "top3_mean": 0.7700458320360336 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0625867247581482, + "best_mean": 0.008783295041038919, + "top3_p10": 0.04141137997309363, + "top3_mean": 0.0075074651884656785 + }, + "top_candidates": [ + { + "face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "photo_name": "IMG_0146.JPG", + "source_type": "cluster", + "cluster_label": 2, + "detector_score": 0.779296875, + "metrics": { + "prototype_count": 5, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.7093561887741089, + "best_median": 0.8368068933486938, + "best_mean": 0.8004826903343201, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6665714184443156, + "top3_mean": 0.7700458320360336 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0625867247581482, + "best_mean": 0.008783295041038919, + "top3_p10": 0.04141137997309363, + "top3_mean": 0.0075074651884656785 + } + }, + { + "face_id": "face:e30a1427708cfc8d63a66d369781b15ac6a07b43e13550b1a7625003914503ed:0", + "photo_name": "IMG_0190.JPG", + "source_type": "cluster", + "cluster_label": 4, + "detector_score": 0.8671875, + "metrics": { + "prototype_count": 5, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6704736351966858, + "best_median": 0.8365657329559326, + "best_mean": 0.7974519360633123, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6525195837020874, + "top3_mean": 0.7695946400127714 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.023704171180725098, + "best_mean": 0.005752540770031178, + "top3_p10": 0.02735954523086548, + "top3_mean": 0.007056273165203519 + } + }, + { + "face_id": "face:10ca45bc5aab42b5ba08da7d6a15d7f942478ca5e052f89054097c93d3dd6134:0", + "photo_name": "IMG_0135.JPG", + "source_type": "cluster", + "cluster_label": 2, + "detector_score": 0.776367188, + "metrics": { + "prototype_count": 5, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6589575409889221, + "best_median": 0.8329968452453613, + "best_mean": 0.7944277099200657, + "top3_min": 0.4437006612618764, + "top3_p10": 0.6292567451794943, + "top3_mean": 0.7695285058210767 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.012188076972961426, + "best_mean": 0.002728314626784578, + "top3_p10": 0.004096706708272335, + "top3_mean": 0.006990138973508753 + } + }, + { + "face_id": "face:dd715de9995fd11a23fee1df7c1561ea45bd215e765e9e93b2efc3f2ca59c39c:0", + "photo_name": "IMG_0147.JPG", + "source_type": "cluster", + "cluster_label": 2, + "detector_score": 0.802734375, + "metrics": { + "prototype_count": 5, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6467694640159607, + "best_median": 0.8415970206260681, + "best_mean": 0.7978796022278922, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6316222151120504, + "top3_mean": 0.7708096778582012 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0, + "best_mean": 0.006180206934611077, + "top3_p10": 0.0064621766408284875, + "top3_mean": 0.008271311010633275 + } + }, + { + "face_id": "face:915492fbf9fc1769985e4f5684d42728b5ee63092b148b887c6d07d1fb66fe8f:0", + "photo_name": "IMG_0217.JPG", + "source_type": "noise", + "cluster_label": null, + "detector_score": 0.919433594, + "metrics": { + "prototype_count": 5, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6467694640159607, + "best_median": 0.8365657329559326, + "best_mean": 0.7975525004523141, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6251600384712219, + "top3_mean": 0.7677394993721494 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0, + "best_mean": 0.005853105159032923, + "top3_p10": 0.0, + "top3_mean": 0.005201132524581431 + } + }, + { + "face_id": "face:0bd35f8c46fa2ec4979d977d13bab6b8e0dc18365a0f25a7d87d81c5336c0378:0", + "photo_name": "IMG_0182.JPG", + "source_type": "noise", + "cluster_label": null, + "detector_score": 0.680175781, + "metrics": { + "prototype_count": 5, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6467694640159607, + "best_median": 0.8329968452453613, + "best_mean": 0.7937582419032142, + "top3_min": 0.43891142805417377, + "top3_p10": 0.6251600384712219, + "top3_mean": 0.7642595961926475 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0, + "best_mean": 0.0020588466099330516, + "top3_p10": 0.0, + "top3_mean": 0.0017212293450795668 + } + }, + { + "face_id": "face:b1f9d25d6d7217c01a3a67f59176d54ce73b1bb626a687d1447a5354e5cad020:0", + "photo_name": "IMG_0244.JPG", + "source_type": "cluster", + "cluster_label": 4, + "detector_score": 0.874511719, + "metrics": { + "prototype_count": 5, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6467694640159607, + "best_median": 0.8368068933486938, + "best_mean": 0.7937270686739967, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6251600384712219, + "top3_mean": 0.7697697111538478 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0, + "best_mean": 0.0020276733807155223, + "top3_p10": 0.0, + "top3_mean": 0.007231344306279919 + } + }, + { + "face_id": "face:ddab26bedbe3287cb210d46a2397336f879655c99224f90f8da1646ee111fee8:0", + "photo_name": "IMG_0167.JPG", + "source_type": "noise", + "cluster_label": null, + "detector_score": 0.866699219, + "metrics": { + "prototype_count": 5, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6467694640159607, + "best_median": 0.8329968452453613, + "best_mean": 0.7930840310596284, + "top3_min": 0.43410436312357586, + "top3_p10": 0.6251600384712219, + "top3_mean": 0.7682123997854807 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0, + "best_mean": 0.0013846357663472864, + "top3_p10": 0.0, + "top3_mean": 0.0056740329379127985 + } + }, + { + "face_id": "face:8e1a2d2abbd36803ec8670f56869ae723c4c8c8fcd5e634ada9f988c942df1b8:0", + "photo_name": "IMG_0148.JPG", + "source_type": "cluster", + "cluster_label": 3, + "detector_score": 0.845214844, + "metrics": { + "prototype_count": 5, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6467694640159607, + "best_median": 0.8329968452453613, + "best_mean": 0.7920549880890619, + "top3_min": 0.442541499932607, + "top3_p10": 0.6251600384712219, + "top3_mean": 0.765577689049736 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0, + "best_mean": 0.0003555927957807903, + "top3_p10": 0.0, + "top3_mean": 0.003039322202168071 + } + }, + { + "face_id": "face:3041f044b86b8888937659a1c691db34f0c8b52b6dec6a7db023c18e9cd24722:0", + "photo_name": "IMG_0209.JPG", + "source_type": "noise", + "cluster_label": null, + "detector_score": 0.650878906, + "metrics": { + "prototype_count": 5, + "below_0.50": 1, + "below_0.60": 1, + "best_min": 0.4664280414581299, + "best_p10": 0.6467694640159607, + "best_median": 0.8329968452453613, + "best_mean": 0.7916993952932811, + "top3_min": 0.4529097576936086, + "top3_p10": 0.6251600384712219, + "top3_mean": 0.7657087260768527 + }, + "metric_delta": { + "below_0.50": 0, + "below_0.60": 0, + "best_min": 0.0, + "best_p10": 0.0, + "best_mean": 0.0, + "top3_p10": 0.0, + "top3_mean": 0.0031703592292847693 + } + } + ] + } + ], + "evaluations": { + "cluster_medoids": { + "summary": { + "configuration": "cluster_medoids", + "prototype_count": 3, + "query_count": 21, + "best_similarity": { + "minimum": 0.4346284568309784, + "p10": 0.6467694640159607, + "median": 0.8265709280967712, + "mean": 0.7850906749566396, + "maximum": 0.9300960302352905 + }, + "top3_mean": { + "minimum": 0.40839412808418274, + "p10": 0.6083214084307352, + "median": 0.7912937998771667, + "mean": 0.7489666281238434, + "maximum": 0.8516350388526917 + }, + "counts_below": { + "0.30": 0, + "0.40": 0, + "0.50": 1, + "0.60": 1 + } + }, + "queries": [ + { + "face_id": "face:4025ea19107cf40d092576e4aaa122bdfb027dc90592a50dd6fa8131838dae69:0", + "photo_name": "IMG_0153.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.861328125, + "is_prototype": false, + "best_similarity": 0.8124435544013977, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.7658378481864929 + }, + { + "face_id": "face:ddab26bedbe3287cb210d46a2397336f879655c99224f90f8da1646ee111fee8:0", + "photo_name": "IMG_0167.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.866699219, + "is_prototype": false, + "best_similarity": 0.8265709280967712, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.7975235382715861 + }, + { + "face_id": "face:0bd35f8c46fa2ec4979d977d13bab6b8e0dc18365a0f25a7d87d81c5336c0378:0", + "photo_name": "IMG_0182.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.680175781, + "is_prototype": false, + "best_similarity": 0.7292512655258179, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.7224738597869873 + }, + { + "face_id": "face:4065f86b9af59235a5727abaaccb54a9a91fa585b464da2c592db5ed300d8836:0", + "photo_name": "IMG_0183.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.680664062, + "is_prototype": false, + "best_similarity": 0.4346284568309784, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.40839412808418274 + }, + { + "face_id": "face:77b822134129d751070d73441dda90996dab21186c50be2ce79064e22bba94bd:0", + "photo_name": "IMG_0203.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.778808594, + "is_prototype": false, + "best_similarity": 0.7473223209381104, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.7229786316553751 + }, + { + "face_id": "face:3041f044b86b8888937659a1c691db34f0c8b52b6dec6a7db023c18e9cd24722:0", + "photo_name": "IMG_0209.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.650878906, + "is_prototype": false, + "best_similarity": 0.8415970206260681, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.7912937998771667 + }, + { + "face_id": "face:2454c04dd9067d2b5288dcca99c5c3eac85660779a97d6165a08ca7c37481070:0", + "photo_name": "IMG_0214.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.882324219, + "is_prototype": false, + "best_similarity": 0.6467694640159607, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.6083214084307352 + }, + { + "face_id": "face:a99f57f0bea2cb9b6caf3d0e60c3719e379d3d5bc2bbe37d1044c764214dd0b9:0", + "photo_name": "IMG_0216.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.890136719, + "is_prototype": false, + "best_similarity": 0.7094748616218567, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.6847055951754252 + }, + { + "face_id": "face:915492fbf9fc1769985e4f5684d42728b5ee63092b148b887c6d07d1fb66fe8f:0", + "photo_name": "IMG_0217.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.919433594, + "is_prototype": false, + "best_similarity": 0.8368068933486938, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.7968581120173136 + }, + { + "face_id": "face:1a4313d5086018efcd689dbca06040d99a75f7b331798b0fa2ced0d25dea95b7:0", + "photo_name": "IMG_0232.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.807128906, + "is_prototype": false, + "best_similarity": 0.6016629338264465, + "best_prototype_face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "top3_mean": 0.5769451260566711 + }, + { + "face_id": "face:e9549aa2bb21ad70ff35ad1626cf15f6cb62a321e25e3e7ab832bd9e7430e186:0", + "photo_name": "IMG_0245.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.744140625, + "is_prototype": false, + "best_similarity": 0.6910650730133057, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.6800989309946696 + }, + { + "face_id": "face:10ca45bc5aab42b5ba08da7d6a15d7f942478ca5e052f89054097c93d3dd6134:0", + "photo_name": "IMG_0135.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.776367188, + "is_prototype": false, + "best_similarity": 0.8179458379745483, + "best_prototype_face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "top3_mean": 0.8021900455156962 + }, + { + "face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "photo_name": "IMG_0145.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.80078125, + "is_prototype": true, + "best_similarity": 0.8329968452453613, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.7952806651592255 + }, + { + "face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "photo_name": "IMG_0146.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.779296875, + "is_prototype": false, + "best_similarity": 0.9133110642433167, + "best_prototype_face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "top3_mean": 0.8393413027127584 + }, + { + "face_id": "face:dd715de9995fd11a23fee1df7c1561ea45bd215e765e9e93b2efc3f2ca59c39c:0", + "photo_name": "IMG_0147.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.802734375, + "is_prototype": false, + "best_similarity": 0.8798930644989014, + "best_prototype_face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "top3_mean": 0.8438834547996521 + }, + { + "face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "photo_name": "IMG_0141.JPG", + "cluster_label": 3, + "source_type": "cluster", + "detector_score": 0.764160156, + "is_prototype": true, + "best_similarity": 0.8218786120414734, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.7897215485572815 + }, + { + "face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "photo_name": "IMG_0143.JPG", + "cluster_label": 3, + "source_type": "cluster", + "detector_score": 0.860839844, + "is_prototype": false, + "best_similarity": 0.8591138124465942, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.8154405752817789 + }, + { + "face_id": "face:8e1a2d2abbd36803ec8670f56869ae723c4c8c8fcd5e634ada9f988c942df1b8:0", + "photo_name": "IMG_0148.JPG", + "cluster_label": 3, + "source_type": "cluster", + "detector_score": 0.845214844, + "is_prototype": false, + "best_similarity": 0.8570680022239685, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.7806732654571533 + }, + { + "face_id": "face:e30a1427708cfc8d63a66d369781b15ac6a07b43e13550b1a7625003914503ed:0", + "photo_name": "IMG_0190.JPG", + "cluster_label": 4, + "source_type": "cluster", + "detector_score": 0.8671875, + "is_prototype": false, + "best_similarity": 0.9300960302352905, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.8516350388526917 + }, + { + "face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "photo_name": "IMG_0193.JPG", + "cluster_label": 4, + "source_type": "cluster", + "detector_score": 0.905273438, + "is_prototype": true, + "best_similarity": 0.8329968452453613, + "best_prototype_face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "top3_mean": 0.8274377286434174 + }, + { + "face_id": "face:b1f9d25d6d7217c01a3a67f59176d54ce73b1bb626a687d1447a5354e5cad020:0", + "photo_name": "IMG_0244.JPG", + "cluster_label": 4, + "source_type": "cluster", + "detector_score": 0.874511719, + "is_prototype": false, + "best_similarity": 0.864011287689209, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.8272645870844523 + } + ] + }, + "cluster_medoids_plus_noise": { + "summary": { + "configuration": "cluster_medoids_plus_noise", + "prototype_count": 5, + "query_count": 21, + "best_similarity": { + "minimum": 0.4346284568309784, + "p10": 0.6467694640159607, + "median": 0.8265709280967712, + "mean": 0.7850906749566396, + "maximum": 0.9300960302352905 + }, + "top3_mean": { + "minimum": 0.40839412808418274, + "p10": 0.6083214084307352, + "median": 0.7672149737675985, + "mean": 0.7396416167418162, + "maximum": 0.8516350388526917 + }, + "counts_below": { + "0.30": 0, + "0.40": 0, + "0.50": 1, + "0.60": 1 + } + }, + "queries": [ + { + "face_id": "face:4025ea19107cf40d092576e4aaa122bdfb027dc90592a50dd6fa8131838dae69:0", + "photo_name": "IMG_0153.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.861328125, + "is_prototype": false, + "best_similarity": 0.8124435544013977, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.7658378481864929 + }, + { + "face_id": "face:ddab26bedbe3287cb210d46a2397336f879655c99224f90f8da1646ee111fee8:0", + "photo_name": "IMG_0167.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.866699219, + "is_prototype": false, + "best_similarity": 0.8265709280967712, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.7975235382715861 + }, + { + "face_id": "face:0bd35f8c46fa2ec4979d977d13bab6b8e0dc18365a0f25a7d87d81c5336c0378:0", + "photo_name": "IMG_0182.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.680175781, + "is_prototype": false, + "best_similarity": 0.7292512655258179, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.7224738597869873 + }, + { + "face_id": "face:4065f86b9af59235a5727abaaccb54a9a91fa585b464da2c592db5ed300d8836:0", + "photo_name": "IMG_0183.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.680664062, + "is_prototype": true, + "best_similarity": 0.4346284568309784, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.40839412808418274 + }, + { + "face_id": "face:77b822134129d751070d73441dda90996dab21186c50be2ce79064e22bba94bd:0", + "photo_name": "IMG_0203.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.778808594, + "is_prototype": false, + "best_similarity": 0.7473223209381104, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.7229786316553751 + }, + { + "face_id": "face:3041f044b86b8888937659a1c691db34f0c8b52b6dec6a7db023c18e9cd24722:0", + "photo_name": "IMG_0209.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.650878906, + "is_prototype": false, + "best_similarity": 0.8415970206260681, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.7912937998771667 + }, + { + "face_id": "face:2454c04dd9067d2b5288dcca99c5c3eac85660779a97d6165a08ca7c37481070:0", + "photo_name": "IMG_0214.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.882324219, + "is_prototype": true, + "best_similarity": 0.6467694640159607, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.6083214084307352 + }, + { + "face_id": "face:a99f57f0bea2cb9b6caf3d0e60c3719e379d3d5bc2bbe37d1044c764214dd0b9:0", + "photo_name": "IMG_0216.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.890136719, + "is_prototype": false, + "best_similarity": 0.7094748616218567, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.6847055951754252 + }, + { + "face_id": "face:915492fbf9fc1769985e4f5684d42728b5ee63092b148b887c6d07d1fb66fe8f:0", + "photo_name": "IMG_0217.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.919433594, + "is_prototype": false, + "best_similarity": 0.8368068933486938, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.7968581120173136 + }, + { + "face_id": "face:1a4313d5086018efcd689dbca06040d99a75f7b331798b0fa2ced0d25dea95b7:0", + "photo_name": "IMG_0232.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.807128906, + "is_prototype": false, + "best_similarity": 0.6016629338264465, + "best_prototype_face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "top3_mean": 0.5769451260566711 + }, + { + "face_id": "face:e9549aa2bb21ad70ff35ad1626cf15f6cb62a321e25e3e7ab832bd9e7430e186:0", + "photo_name": "IMG_0245.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.744140625, + "is_prototype": false, + "best_similarity": 0.6910650730133057, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.6800989309946696 + }, + { + "face_id": "face:10ca45bc5aab42b5ba08da7d6a15d7f942478ca5e052f89054097c93d3dd6134:0", + "photo_name": "IMG_0135.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.776367188, + "is_prototype": false, + "best_similarity": 0.8179458379745483, + "best_prototype_face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "top3_mean": 0.8021900455156962 + }, + { + "face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "photo_name": "IMG_0145.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.80078125, + "is_prototype": true, + "best_similarity": 0.8329968452453613, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.7436256607373556 + }, + { + "face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "photo_name": "IMG_0146.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.779296875, + "is_prototype": false, + "best_similarity": 0.9133110642433167, + "best_prototype_face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "top3_mean": 0.8393413027127584 + }, + { + "face_id": "face:dd715de9995fd11a23fee1df7c1561ea45bd215e765e9e93b2efc3f2ca59c39c:0", + "photo_name": "IMG_0147.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.802734375, + "is_prototype": false, + "best_similarity": 0.8798930644989014, + "best_prototype_face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "top3_mean": 0.8438834547996521 + }, + { + "face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "photo_name": "IMG_0141.JPG", + "cluster_label": 3, + "source_type": "cluster", + "detector_score": 0.764160156, + "is_prototype": true, + "best_similarity": 0.8218786120414734, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.7057740688323975 + }, + { + "face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "photo_name": "IMG_0143.JPG", + "cluster_label": 3, + "source_type": "cluster", + "detector_score": 0.860839844, + "is_prototype": false, + "best_similarity": 0.8591138124465942, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.8154405752817789 + }, + { + "face_id": "face:8e1a2d2abbd36803ec8670f56869ae723c4c8c8fcd5e634ada9f988c942df1b8:0", + "photo_name": "IMG_0148.JPG", + "cluster_label": 3, + "source_type": "cluster", + "detector_score": 0.845214844, + "is_prototype": false, + "best_similarity": 0.8570680022239685, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.7806732654571533 + }, + { + "face_id": "face:e30a1427708cfc8d63a66d369781b15ac6a07b43e13550b1a7625003914503ed:0", + "photo_name": "IMG_0190.JPG", + "cluster_label": 4, + "source_type": "cluster", + "detector_score": 0.8671875, + "is_prototype": false, + "best_similarity": 0.9300960302352905, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.8516350388526917 + }, + { + "face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "photo_name": "IMG_0193.JPG", + "cluster_label": 4, + "source_type": "cluster", + "detector_score": 0.905273438, + "is_prototype": true, + "best_similarity": 0.8329968452453613, + "best_prototype_face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "top3_mean": 0.7672149737675985 + }, + { + "face_id": "face:b1f9d25d6d7217c01a3a67f59176d54ce73b1bb626a687d1447a5354e5cad020:0", + "photo_name": "IMG_0244.JPG", + "cluster_label": 4, + "source_type": "cluster", + "detector_score": 0.874511719, + "is_prototype": false, + "best_similarity": 0.864011287689209, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.8272645870844523 + } + ] + }, + "greedy_gain": { + "summary": { + "configuration": "greedy_gain", + "prototype_count": 5, + "query_count": 21, + "best_similarity": { + "minimum": 0.4664280414581299, + "p10": 0.7093561887741089, + "median": 0.8368068933486938, + "mean": 0.8004826903343201, + "maximum": 0.9300960302352905 + }, + "top3_mean": { + "minimum": 0.43410436312357586, + "p10": 0.6665714184443156, + "median": 0.8155554135640463, + "mean": 0.7700458320360336, + "maximum": 0.8624454736709595 + }, + "counts_below": { + "0.30": 0, + "0.40": 0, + "0.50": 1, + "0.60": 1 + } + }, + "queries": [ + { + "face_id": "face:4025ea19107cf40d092576e4aaa122bdfb027dc90592a50dd6fa8131838dae69:0", + "photo_name": "IMG_0153.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.861328125, + "is_prototype": false, + "best_similarity": 0.8124435544013977, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.7888006965319315 + }, + { + "face_id": "face:ddab26bedbe3287cb210d46a2397336f879655c99224f90f8da1646ee111fee8:0", + "photo_name": "IMG_0167.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.866699219, + "is_prototype": false, + "best_similarity": 0.8365657329559326, + "best_prototype_face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "top3_mean": 0.8244038422902426 + }, + { + "face_id": "face:0bd35f8c46fa2ec4979d977d13bab6b8e0dc18365a0f25a7d87d81c5336c0378:0", + "photo_name": "IMG_0182.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.680175781, + "is_prototype": false, + "best_similarity": 0.7688858509063721, + "best_prototype_face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "top3_mean": 0.7423757712046305 + }, + { + "face_id": "face:4065f86b9af59235a5727abaaccb54a9a91fa585b464da2c592db5ed300d8836:0", + "photo_name": "IMG_0183.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.680664062, + "is_prototype": false, + "best_similarity": 0.4664280414581299, + "best_prototype_face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "top3_mean": 0.43410436312357586 + }, + { + "face_id": "face:77b822134129d751070d73441dda90996dab21186c50be2ce79064e22bba94bd:0", + "photo_name": "IMG_0203.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.778808594, + "is_prototype": false, + "best_similarity": 0.7491501569747925, + "best_prototype_face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "top3_mean": 0.7460532784461975 + }, + { + "face_id": "face:3041f044b86b8888937659a1c691db34f0c8b52b6dec6a7db023c18e9cd24722:0", + "photo_name": "IMG_0209.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.650878906, + "is_prototype": false, + "best_similarity": 0.8415970206260681, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.8051130771636963 + }, + { + "face_id": "face:2454c04dd9067d2b5288dcca99c5c3eac85660779a97d6165a08ca7c37481070:0", + "photo_name": "IMG_0214.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.882324219, + "is_prototype": false, + "best_similarity": 0.7126291394233704, + "best_prototype_face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "top3_mean": 0.6665714184443156 + }, + { + "face_id": "face:a99f57f0bea2cb9b6caf3d0e60c3719e379d3d5bc2bbe37d1044c764214dd0b9:0", + "photo_name": "IMG_0216.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.890136719, + "is_prototype": false, + "best_similarity": 0.7094748616218567, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.6989921728769938 + }, + { + "face_id": "face:915492fbf9fc1769985e4f5684d42728b5ee63092b148b887c6d07d1fb66fe8f:0", + "photo_name": "IMG_0217.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.919433594, + "is_prototype": false, + "best_similarity": 0.8368068933486938, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.8191494544347128 + }, + { + "face_id": "face:1a4313d5086018efcd689dbca06040d99a75f7b331798b0fa2ced0d25dea95b7:0", + "photo_name": "IMG_0232.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.807128906, + "is_prototype": false, + "best_similarity": 0.6206879019737244, + "best_prototype_face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "top3_mean": 0.6054657300313314 + }, + { + "face_id": "face:e9549aa2bb21ad70ff35ad1626cf15f6cb62a321e25e3e7ab832bd9e7430e186:0", + "photo_name": "IMG_0245.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.744140625, + "is_prototype": false, + "best_similarity": 0.7093561887741089, + "best_prototype_face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "top3_mean": 0.6953352689743042 + }, + { + "face_id": "face:10ca45bc5aab42b5ba08da7d6a15d7f942478ca5e052f89054097c93d3dd6134:0", + "photo_name": "IMG_0135.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.776367188, + "is_prototype": false, + "best_similarity": 0.8311539888381958, + "best_prototype_face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "top3_mean": 0.8206387360890707 + }, + { + "face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "photo_name": "IMG_0145.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.80078125, + "is_prototype": true, + "best_similarity": 0.9133110642433167, + "best_prototype_face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "top3_mean": 0.8346241315205892 + }, + { + "face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "photo_name": "IMG_0146.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.779296875, + "is_prototype": true, + "best_similarity": 0.9133110642433167, + "best_prototype_face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "top3_mean": 0.8393413027127584 + }, + { + "face_id": "face:dd715de9995fd11a23fee1df7c1561ea45bd215e765e9e93b2efc3f2ca59c39c:0", + "photo_name": "IMG_0147.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.802734375, + "is_prototype": false, + "best_similarity": 0.8798930644989014, + "best_prototype_face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "top3_mean": 0.8624454736709595 + }, + { + "face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "photo_name": "IMG_0141.JPG", + "cluster_label": 3, + "source_type": "cluster", + "detector_score": 0.764160156, + "is_prototype": true, + "best_similarity": 0.8591138124465942, + "best_prototype_face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "top3_mean": 0.8155554135640463 + }, + { + "face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "photo_name": "IMG_0143.JPG", + "cluster_label": 3, + "source_type": "cluster", + "detector_score": 0.860839844, + "is_prototype": true, + "best_similarity": 0.8591138124465942, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.8178731203079224 + }, + { + "face_id": "face:8e1a2d2abbd36803ec8670f56869ae723c4c8c8fcd5e634ada9f988c942df1b8:0", + "photo_name": "IMG_0148.JPG", + "cluster_label": 3, + "source_type": "cluster", + "detector_score": 0.845214844, + "is_prototype": false, + "best_similarity": 0.8570680022239685, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.8127076427141825 + }, + { + "face_id": "face:e30a1427708cfc8d63a66d369781b15ac6a07b43e13550b1a7625003914503ed:0", + "photo_name": "IMG_0190.JPG", + "cluster_label": 4, + "source_type": "cluster", + "detector_score": 0.8671875, + "is_prototype": false, + "best_similarity": 0.9300960302352905, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.857469399770101 + }, + { + "face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "photo_name": "IMG_0193.JPG", + "cluster_label": 4, + "source_type": "cluster", + "detector_score": 0.905273438, + "is_prototype": true, + "best_similarity": 0.8390390276908875, + "best_prototype_face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "top3_mean": 0.8347755074501038 + }, + { + "face_id": "face:b1f9d25d6d7217c01a3a67f59176d54ce73b1bb626a687d1447a5354e5cad020:0", + "photo_name": "IMG_0244.JPG", + "cluster_label": 4, + "source_type": "cluster", + "detector_score": 0.874511719, + "is_prototype": false, + "best_similarity": 0.864011287689209, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.8491666714350382 + } + ] + }, + "all_confirmed": { + "summary": { + "configuration": "all_confirmed", + "prototype_count": 21, + "query_count": 21, + "best_similarity": { + "minimum": 0.4664280414581299, + "p10": 0.7093561887741089, + "median": 0.8481324315071106, + "mean": 0.8149522826785133, + "maximum": 0.9300960302352905 + }, + "top3_mean": { + "minimum": 0.4529097576936086, + "p10": 0.676624079545339, + "median": 0.8318496545155843, + "mean": 0.7901670682051826, + "maximum": 0.8826222022374471 + }, + "counts_below": { + "0.30": 0, + "0.40": 0, + "0.50": 1, + "0.60": 1 + } + }, + "queries": [ + { + "face_id": "face:4025ea19107cf40d092576e4aaa122bdfb027dc90592a50dd6fa8131838dae69:0", + "photo_name": "IMG_0153.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.861328125, + "is_prototype": true, + "best_similarity": 0.8124435544013977, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.8031478126843771 + }, + { + "face_id": "face:ddab26bedbe3287cb210d46a2397336f879655c99224f90f8da1646ee111fee8:0", + "photo_name": "IMG_0167.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.866699219, + "is_prototype": true, + "best_similarity": 0.8481324315071106, + "best_prototype_face_id": "face:b1f9d25d6d7217c01a3a67f59176d54ce73b1bb626a687d1447a5354e5cad020:0", + "top3_mean": 0.8377184867858887 + }, + { + "face_id": "face:0bd35f8c46fa2ec4979d977d13bab6b8e0dc18365a0f25a7d87d81c5336c0378:0", + "photo_name": "IMG_0182.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.680175781, + "is_prototype": true, + "best_similarity": 0.7688858509063721, + "best_prototype_face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "top3_mean": 0.7504501342773438 + }, + { + "face_id": "face:4065f86b9af59235a5727abaaccb54a9a91fa585b464da2c592db5ed300d8836:0", + "photo_name": "IMG_0183.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.680664062, + "is_prototype": true, + "best_similarity": 0.4664280414581299, + "best_prototype_face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "top3_mean": 0.4529097576936086 + }, + { + "face_id": "face:77b822134129d751070d73441dda90996dab21186c50be2ce79064e22bba94bd:0", + "photo_name": "IMG_0203.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.778808594, + "is_prototype": true, + "best_similarity": 0.7491501569747925, + "best_prototype_face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "top3_mean": 0.7460532784461975 + }, + { + "face_id": "face:3041f044b86b8888937659a1c691db34f0c8b52b6dec6a7db023c18e9cd24722:0", + "photo_name": "IMG_0209.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.650878906, + "is_prototype": true, + "best_similarity": 0.8415970206260681, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.8116465210914612 + }, + { + "face_id": "face:2454c04dd9067d2b5288dcca99c5c3eac85660779a97d6165a08ca7c37481070:0", + "photo_name": "IMG_0214.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.882324219, + "is_prototype": true, + "best_similarity": 0.7126291394233704, + "best_prototype_face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "top3_mean": 0.676624079545339 + }, + { + "face_id": "face:a99f57f0bea2cb9b6caf3d0e60c3719e379d3d5bc2bbe37d1044c764214dd0b9:0", + "photo_name": "IMG_0216.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.890136719, + "is_prototype": true, + "best_similarity": 0.8285800218582153, + "best_prototype_face_id": "face:915492fbf9fc1769985e4f5684d42728b5ee63092b148b887c6d07d1fb66fe8f:0", + "top3_mean": 0.7613581816355387 + }, + { + "face_id": "face:915492fbf9fc1769985e4f5684d42728b5ee63092b148b887c6d07d1fb66fe8f:0", + "photo_name": "IMG_0217.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.919433594, + "is_prototype": true, + "best_similarity": 0.8368068933486938, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.8318496545155843 + }, + { + "face_id": "face:1a4313d5086018efcd689dbca06040d99a75f7b331798b0fa2ced0d25dea95b7:0", + "photo_name": "IMG_0232.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.807128906, + "is_prototype": true, + "best_similarity": 0.6589575409889221, + "best_prototype_face_id": "face:10ca45bc5aab42b5ba08da7d6a15d7f942478ca5e052f89054097c93d3dd6134:0", + "top3_mean": 0.6415147185325623 + }, + { + "face_id": "face:e9549aa2bb21ad70ff35ad1626cf15f6cb62a321e25e3e7ab832bd9e7430e186:0", + "photo_name": "IMG_0245.JPG", + "cluster_label": null, + "source_type": "noise", + "detector_score": 0.744140625, + "is_prototype": true, + "best_similarity": 0.7093561887741089, + "best_prototype_face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "top3_mean": 0.7027677893638611 + }, + { + "face_id": "face:10ca45bc5aab42b5ba08da7d6a15d7f942478ca5e052f89054097c93d3dd6134:0", + "photo_name": "IMG_0135.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.776367188, + "is_prototype": true, + "best_similarity": 0.8750169277191162, + "best_prototype_face_id": "face:dd715de9995fd11a23fee1df7c1561ea45bd215e765e9e93b2efc3f2ca59c39c:0", + "top3_mean": 0.8413722515106201 + }, + { + "face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "photo_name": "IMG_0145.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.80078125, + "is_prototype": true, + "best_similarity": 0.9133110642433167, + "best_prototype_face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "top3_mean": 0.8754003246625265 + }, + { + "face_id": "face:5d03b5d0866a5cce0b6a12b78173c1f77eef5b1e5bfc5a4fd8e2cfd1c53a2b8e:0", + "photo_name": "IMG_0146.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.779296875, + "is_prototype": true, + "best_similarity": 0.9133110642433167, + "best_prototype_face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "top3_mean": 0.8686780532201132 + }, + { + "face_id": "face:dd715de9995fd11a23fee1df7c1561ea45bd215e765e9e93b2efc3f2ca59c39c:0", + "photo_name": "IMG_0147.JPG", + "cluster_label": 2, + "source_type": "cluster", + "detector_score": 0.802734375, + "is_prototype": true, + "best_similarity": 0.8798930644989014, + "best_prototype_face_id": "face:f56db1595a3b822e1e5cc119c101904285dca3f3f1b8edbf0bfa42999583cc2f:0", + "top3_mean": 0.8695564270019531 + }, + { + "face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "photo_name": "IMG_0141.JPG", + "cluster_label": 3, + "source_type": "cluster", + "detector_score": 0.764160156, + "is_prototype": true, + "best_similarity": 0.8591138124465942, + "best_prototype_face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "top3_mean": 0.852592945098877 + }, + { + "face_id": "face:c782108a839231c6dda63924f0b855747f98a4f56d8e93390f70877d4a452db6:0", + "photo_name": "IMG_0143.JPG", + "cluster_label": 3, + "source_type": "cluster", + "detector_score": 0.860839844, + "is_prototype": true, + "best_similarity": 0.8591138124465942, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.8501997391382853 + }, + { + "face_id": "face:8e1a2d2abbd36803ec8670f56869ae723c4c8c8fcd5e634ada9f988c942df1b8:0", + "photo_name": "IMG_0148.JPG", + "cluster_label": 3, + "source_type": "cluster", + "detector_score": 0.845214844, + "is_prototype": true, + "best_similarity": 0.8570680022239685, + "best_prototype_face_id": "face:ebaf024e4df7764c2c458484d6e6eef1ced76611dd70b84f82af46337ba13839:0", + "top3_mean": 0.8206974864006042 + }, + { + "face_id": "face:e30a1427708cfc8d63a66d369781b15ac6a07b43e13550b1a7625003914503ed:0", + "photo_name": "IMG_0190.JPG", + "cluster_label": 4, + "source_type": "cluster", + "detector_score": 0.8671875, + "is_prototype": true, + "best_similarity": 0.9300960302352905, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.8606607913970947 + }, + { + "face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "photo_name": "IMG_0193.JPG", + "cluster_label": 4, + "source_type": "cluster", + "detector_score": 0.905273438, + "is_prototype": true, + "best_similarity": 0.9300960302352905, + "best_prototype_face_id": "face:e30a1427708cfc8d63a66d369781b15ac6a07b43e13550b1a7625003914503ed:0", + "top3_mean": 0.8826222022374471 + }, + { + "face_id": "face:b1f9d25d6d7217c01a3a67f59176d54ce73b1bb626a687d1447a5354e5cad020:0", + "photo_name": "IMG_0244.JPG", + "cluster_label": 4, + "source_type": "cluster", + "detector_score": 0.874511719, + "is_prototype": true, + "best_similarity": 0.864011287689209, + "best_prototype_face_id": "face:c584bb41bc73b6b452c6964622ea7e4ccfddc741ed6e1d3757ed6015b59cf65f:0", + "top3_mean": 0.8556877970695496 + } + ] + } + }, + "notes": [ + "Questa prova misura la copertura dei volti confermati della stessa persona.", + "Non misura i falsi positivi perché è disponibile una sola persona confermata.", + "La selezione non modifica il database." + ] +} diff --git a/tools/runActivityIngestDaemon.js b/tools/runActivityIngestDaemon.js new file mode 100644 index 0000000..981b7e6 --- /dev/null +++ b/tools/runActivityIngestDaemon.js @@ -0,0 +1,407 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const db = require('../db/knex'); + +const { + ensureActivityIngestQueue, + resetStaleActivityIngestJobs, + getActivityIngestQueueCounts, +} = require( + '../api_v1/scanner/activityIngestQueue' +); + +const { + runOneJob, +} = require( + '../api_v1/scanner/activityIngestWorker' +); + +const PROJECT_ROOT = path.resolve( + __dirname, + '..' +); + +const DEFAULT_LOCK_PATH = path.join( + PROJECT_ROOT, + 'activity-ingest-daemon.lock' +); + +function positiveInteger(value, option) { + const number = Number(value); + + if ( + !Number.isInteger(number) || + number <= 0 + ) { + throw new Error( + `${option} deve essere un intero positivo` + ); + } + + return number; +} + +function parseArguments(argv) { + const options = { + pollMs: 2000, + resetStaleMinutes: 30, + staleCheckMs: 60000, + lockPath: + process.env.ACTIVITY_INGEST_DAEMON_LOCK || + DEFAULT_LOCK_PATH, + help: false, + dryRun: false, + }; + + for ( + let i = 0; + i < argv.length; + i += 1 + ) { + const arg = argv[i]; + + if ( + arg === '--help' || + arg === '-h' + ) { + options.help = true; + continue; + } + + if (arg === '--dry-run') { + options.dryRun = true; + continue; + } + + const names = { + '--poll-ms': 'pollMs', + '--reset-stale-minutes': + 'resetStaleMinutes', + '--stale-check-ms': + 'staleCheckMs', + '--lock-file': + 'lockPath', + }; + + const key = names[arg]; + + if (!key) { + throw new Error( + `Argomento sconosciuto: ${arg}` + ); + } + + const value = argv[i + 1]; + + if ( + value == null || + value.startsWith('--') + ) { + throw new Error( + `Valore mancante dopo ${arg}` + ); + } + + options[key] = + key === 'lockPath' + ? path.resolve(value) + : positiveInteger(value, arg); + + i += 1; + } + + return options; +} + +function isProcessAlive(pid) { + if ( + !Number.isInteger(pid) || + pid <= 0 + ) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code === 'EPERM'; + } +} + +function readLock(lockPath) { + try { + return JSON.parse( + fs.readFileSync( + lockPath, + 'utf8' + ) + ); + } catch { + return null; + } +} + +function acquireLock(lockPath) { + fs.mkdirSync( + path.dirname(lockPath), + { recursive: true } + ); + + try { + const fd = fs.openSync( + lockPath, + 'wx' + ); + + try { + fs.writeFileSync( + fd, + JSON.stringify( + { + pid: process.pid, + started_at: + new Date().toISOString(), + project_root: PROJECT_ROOT, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + } finally { + fs.closeSync(fd); + } + + return; + } catch (error) { + if (error.code !== 'EEXIST') { + throw error; + } + } + + const existing = + readLock(lockPath); + + const pid = + Number(existing?.pid); + + if (isProcessAlive(pid)) { + throw new Error( + `Daemon gia attivo con PID ${pid}. ` + + `Lock: ${lockPath}` + ); + } + + if ( + !existing || + !Number.isInteger(pid) || + pid <= 0 + ) { + throw new Error( + `Lock esistente non verificabile: ` + + `${lockPath}` + ); + } + + fs.unlinkSync(lockPath); + acquireLock(lockPath); +} + +function releaseLock(lockPath) { + const lock = + readLock(lockPath); + + if ( + Number(lock?.pid) !== process.pid + ) { + return; + } + + try { + fs.unlinkSync(lockPath); + } catch (error) { + if (error.code !== 'ENOENT') { + console.error( + '[ActivityIngestDaemon] ' + + 'Errore rimozione lock:', + error.message + ); + } + } +} + +function sleep(ms) { + return new Promise( + resolve => setTimeout(resolve, ms) + ); +} + +async function main(options) { + if (options.help) { + console.log( + 'Uso: node tools/runActivityIngestDaemon.js [opzioni]\n' + + ' --dry-run\n' + + ' --poll-ms N\n' + + ' --reset-stale-minutes N\n' + + ' --stale-check-ms N\n' + + ' --lock-file PERCORSO' + ); + return; + } + + let stopping = false; + let lockAcquired = false; + let lastStaleCheck = 0; + + const requestStop = signal => { + if (!stopping) { + console.log( + `[ActivityIngestDaemon] ` + + `Ricevuto ${signal}; arresto richiesto.` + ); + + stopping = true; + } + }; + + const onSigint = + () => requestStop('SIGINT'); + + const onSigterm = + () => requestStop('SIGTERM'); + + process.on('SIGINT', onSigint); + process.on('SIGTERM', onSigterm); + + try { + acquireLock(options.lockPath); + lockAcquired = true; + + await ensureActivityIngestQueue(db); + + const recovered = + options.dryRun + ? 0 + : await resetStaleActivityIngestJobs( + db, + options.resetStaleMinutes + ); + + lastStaleCheck = Date.now(); + + console.log( + '[ActivityIngestDaemon] Avviato.', + { + pid: process.pid, + pollMs: options.pollMs, + staleCheckMs: + options.staleCheckMs, + dryRun: options.dryRun, + recovered, + queue: + await getActivityIngestQueueCounts(db), + } + ); + + while (!stopping) { + try { + if (options.dryRun) { + await sleep(options.pollMs); + continue; + } + + const result = + await runOneJob(db); + + if (result.error) { + console.error( + '[ActivityIngestDaemon] ' + + 'Job fallito:', + result.error + ); + } + + /* + * Come Photo daemon: + * se ha processato un job, prova subito + * il successivo. Polling solo a coda vuota. + */ + if ( + !result.processed && + !stopping + ) { + await sleep(options.pollMs); + } + + if ( + !stopping && + Date.now() - lastStaleCheck >= + options.staleCheckMs + ) { + const count = + await resetStaleActivityIngestJobs( + db, + options.resetStaleMinutes + ); + + lastStaleCheck = Date.now(); + + if (count) { + console.warn( + `[ActivityIngestDaemon] ` + + `Recuperati ${count} job bloccati.` + ); + } + } + } catch (error) { + console.error( + '[ActivityIngestDaemon] Errore ciclo:', + error + ); + + if (!stopping) { + await sleep(options.pollMs); + } + } + } + } finally { + process.removeListener( + 'SIGINT', + onSigint + ); + + process.removeListener( + 'SIGTERM', + onSigterm + ); + + if (lockAcquired) { + releaseLock(options.lockPath); + } + } +} + +(async () => { + let options; + + try { + options = parseArguments( + process.argv.slice(2) + ); + + await main(options); + } catch (error) { + console.error( + '[ActivityIngestDaemon] Errore fatale:', + error.message + ); + + process.exitCode = 1; + } finally { + await db.destroy(); + } +})(); diff --git a/tools/runFaceClustering.js b/tools/runFaceClustering.js new file mode 100644 index 0000000..a88f2b4 --- /dev/null +++ b/tools/runFaceClustering.js @@ -0,0 +1,954 @@ +const path = require('path'); +const { spawn } = require('child_process'); +const { randomUUID } = require('crypto'); + +const db = require('../db/knex'); + +const PROJECT_ROOT = path.resolve( + __dirname, + '..', +); + +const DEFAULT_DATABASE_PATH = path.join( + PROJECT_ROOT, + 'api_v1', + 'database.sqlite', +); + +const DEFAULT_PYTHON_PATH = path.join( + PROJECT_ROOT, + '.venv-clustering', + 'bin', + 'python', +); + +const HDBSCAN_SCRIPT_PATH = path.join( + PROJECT_ROOT, + 'tools', + 'clustering', + 'face_hdbscan.py', +); + + +function parsePositiveInteger( + value, + optionName, +) { + const number = Number(value); + + if ( + !Number.isInteger(number) + || number <= 0 + ) { + throw new Error( + `${optionName} deve essere un intero positivo`, + ); + } + + return number; +} + + +function parseArguments(argv) { + const options = { + user: '', + databasePath: DEFAULT_DATABASE_PATH, + pythonPath: DEFAULT_PYTHON_PATH, + minClusterSize: 2, + minSamples: 2, + metric: 'euclidean', + selectionMethod: 'leaf', + scope: 'all', + persist: false, + }; + + for ( + let index = 0; + index < argv.length; + index += 1 + ) { + const argument = argv[index]; + const value = argv[index + 1]; + + if (argument === '--user') { + if (!value) { + throw new Error( + 'Valore mancante dopo --user', + ); + } + + options.user = String(value).trim(); + index += 1; + continue; + } + + if (argument === '--database') { + if (!value) { + throw new Error( + 'Valore mancante dopo --database', + ); + } + + options.databasePath = path.resolve(value); + index += 1; + continue; + } + + if (argument === '--python') { + if (!value) { + throw new Error( + 'Valore mancante dopo --python', + ); + } + + options.pythonPath = path.resolve(value); + index += 1; + continue; + } + + if ( + argument === '--min-cluster-size' + ) { + if (!value) { + throw new Error( + 'Valore mancante dopo ' + + '--min-cluster-size', + ); + } + + options.minClusterSize = + parsePositiveInteger( + value, + '--min-cluster-size', + ); + + index += 1; + continue; + } + + if (argument === '--min-samples') { + if (!value) { + throw new Error( + 'Valore mancante dopo ' + + '--min-samples', + ); + } + + options.minSamples = + parsePositiveInteger( + value, + '--min-samples', + ); + + index += 1; + continue; + } + + if (argument === '--metric') { + if (!value) { + throw new Error( + 'Valore mancante dopo --metric', + ); + } + + options.metric = String(value).trim(); + index += 1; + continue; + } + + if ( + argument === '--selection-method' + ) { + if ( + value !== 'eom' + && value !== 'leaf' + ) { + throw new Error( + '--selection-method deve essere ' + + 'eom oppure leaf', + ); + } + + options.selectionMethod = value; + index += 1; + continue; + } + + if (argument === '--scope') { + if ( + value !== 'all' + && value !== 'unclustered' + ) { + throw new Error( + '--scope deve essere ' + + 'all oppure unclustered', + ); + } + + options.scope = value; + index += 1; + continue; + } + + if (argument === '--persist') { + options.persist = true; + continue; + } + + if ( + argument === '--help' + || argument === '-h' + ) { + options.help = true; + continue; + } + + throw new Error( + `Argomento sconosciuto: ${argument}`, + ); + } + + if (!options.help && !options.user) { + throw new Error( + '--user è obbligatorio', + ); + } + + if (options.minClusterSize < 2) { + throw new Error( + '--min-cluster-size deve essere ' + + 'almeno 2', + ); + } + + if ( + options.persist + && options.scope !== 'all' + ) { + throw new Error( + 'La persistenza iniziale supporta ' + + 'soltanto --scope all', + ); + } + + return options; +} + + +function printHelp() { + console.log(` +Uso: + + node tools/runFaceClustering.js \\ + --user Fabio \\ + [--min-cluster-size 2] \\ + [--min-samples 2] \\ + [--selection-method leaf] \\ + [--metric euclidean] \\ + [--scope all] \\ + [--persist] + +Comportamento: + + Senza --persist: + esegue HDBSCAN e mostra il riepilogo, + senza modificare il database. + + Con --persist: + salva run, cluster e membership + in una singola transazione SQLite. +`.trim()); +} + + +function runPythonClustering(options) { + return new Promise((resolve, reject) => { + const argumentsList = [ + HDBSCAN_SCRIPT_PATH, + '--database', + options.databasePath, + '--user', + options.user, + '--min-cluster-size', + String(options.minClusterSize), + '--min-samples', + String(options.minSamples), + '--metric', + options.metric, + '--selection-method', + options.selectionMethod, + '--scope', + options.scope, + ]; + + const child = spawn( + options.pythonPath, + argumentsList, + { + cwd: PROJECT_ROOT, + stdio: [ + 'ignore', + 'pipe', + 'pipe', + ], + }, + ); + + let stdout = ''; + let stderr = ''; + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + + child.on('error', (error) => { + reject( + new Error( + 'Impossibile avviare Python: ' + + error.message, + ), + ); + }); + + child.on('close', (code, signal) => { + if (code !== 0) { + reject( + new Error( + 'Clustering Python fallito' + + ` (code=${code}, signal=${signal}):\n` + + stderr.trim(), + ), + ); + + return; + } + + let result; + + try { + result = JSON.parse(stdout); + } catch (error) { + reject( + new Error( + 'Output JSON Python non valido: ' + + error.message + + '\nOutput:\n' + + stdout.slice(0, 2000), + ), + ); + + return; + } + + resolve(result); + }); + }); +} + + +function collectAssignments(result) { + const assignments = []; + + for ( + const cluster + of result.clusters || [] + ) { + for ( + const member + of cluster.members || [] + ) { + assignments.push({ + faceId: member.id, + label: Number(cluster.label), + probability: + Number(member.probability), + noiseFlag: 0, + }); + } + } + + for ( + const member + of result.noise || [] + ) { + assignments.push({ + faceId: member.id, + label: -1, + probability: + Number(member.probability || 0), + noiseFlag: 1, + }); + } + + return assignments; +} + + +function validateResult( + result, + options, +) { + if ( + !result + || typeof result !== 'object' + ) { + throw new Error( + 'Risultato clustering mancante', + ); + } + + const parameters = + result.parameters || {}; + + const summary = + result.summary || {}; + + if (parameters.user !== options.user) { + throw new Error( + 'Utente risultato non coerente: ' + + `${parameters.user}`, + ); + } + + if ( + Number(parameters.min_cluster_size) + !== options.minClusterSize + ) { + throw new Error( + 'min_cluster_size non coerente', + ); + } + + if ( + Number(parameters.min_samples) + !== options.minSamples + ) { + throw new Error( + 'min_samples non coerente', + ); + } + + if ( + parameters.cluster_selection_method + !== options.selectionMethod + ) { + throw new Error( + 'selection_method non coerente', + ); + } + + const totalFaces = + Number(summary.total_faces); + + const clusteredFaces = + Number(summary.clustered_faces); + + const noiseFaces = + Number(summary.noise_faces); + + const clusterCount = + Number(summary.cluster_count); + + if ( + !Number.isInteger(totalFaces) + || totalFaces < 2 + ) { + throw new Error( + 'Numero totale volti non valido', + ); + } + + if ( + clusteredFaces + noiseFaces + !== totalFaces + ) { + throw new Error( + 'Riepilogo incoerente: ' + + 'clustered + noise != total', + ); + } + + if ( + !Array.isArray(result.clusters) + || result.clusters.length + !== clusterCount + ) { + throw new Error( + 'Numero cluster non coerente', + ); + } + + const assignments = + collectAssignments(result); + + if ( + assignments.length !== totalFaces + ) { + throw new Error( + 'Numero membership non coerente', + ); + } + + const faceIds = new Set(); + + for (const assignment of assignments) { + if (!assignment.faceId) { + throw new Error( + 'Membership senza face_id', + ); + } + + if (faceIds.has(assignment.faceId)) { + throw new Error( + 'Volto duplicato nel risultato: ' + + assignment.faceId, + ); + } + + faceIds.add(assignment.faceId); + + if ( + !Number.isFinite( + assignment.probability, + ) + || assignment.probability < 0 + || assignment.probability > 1 + ) { + throw new Error( + 'Probabilità non valida per ' + + assignment.faceId, + ); + } + + if ( + assignment.noiseFlag === 1 + && assignment.label !== -1 + ) { + throw new Error( + 'Noise con label differente da -1', + ); + } + + if ( + assignment.noiseFlag === 0 + && assignment.label < 0 + ) { + throw new Error( + 'Cluster con label negativa', + ); + } + } + + for (const cluster of result.clusters) { + const members = + cluster.members || []; + + if ( + Number(cluster.size) + !== members.length + ) { + throw new Error( + 'Dimensione cluster incoerente: ' + + cluster.label, + ); + } + } + + return { + assignments, + totalFaces, + clusteredFaces, + noiseFaces, + clusterCount, + }; +} + + +async function validateDatabaseFaces( + trx, + options, + assignments, +) { + const faceIds = assignments.map( + (assignment) => assignment.faceId, + ); + + const rows = await trx('faces as f') + .join( + 'photos as p', + 'p.id', + 'f.photo_id', + ) + .where( + 'p.user', + options.user, + ) + .whereIn( + 'f.id', + faceIds, + ) + .select( + 'f.id', + 'f.embedding', + ); + + if (rows.length !== faceIds.length) { + const foundIds = new Set( + rows.map((row) => row.id), + ); + + const missingIds = faceIds.filter( + (faceId) => !foundIds.has(faceId), + ); + + throw new Error( + 'Volti mancanti o appartenenti ' + + 'a un altro utente: ' + + missingIds.join(', '), + ); + } + + for (const row of rows) { + if ( + !Buffer.isBuffer(row.embedding) + || row.embedding.length !== 512 + ) { + throw new Error( + 'Embedding non valido per ' + + row.id, + ); + } + } + + const allUserFaces = await trx( + 'faces as f', + ) + .join( + 'photos as p', + 'p.id', + 'f.photo_id', + ) + .where( + 'p.user', + options.user, + ) + .count({ + count: 'f.id', + }) + .first(); + + const totalUserFaces = Number( + allUserFaces?.count || 0, + ); + + if ( + options.scope === 'all' + && totalUserFaces !== faceIds.length + ) { + throw new Error( + 'La run scope=all non contiene ' + + 'tutti i volti dell’utente: ' + + `DB=${totalUserFaces}, ` + + `risultato=${faceIds.length}`, + ); + } +} + + +async function persistResult( + result, + validation, + options, +) { + const runId = randomUUID(); + const now = new Date().toISOString(); + + const clusterIdByLabel = new Map(); + + for (const cluster of result.clusters) { + clusterIdByLabel.set( + Number(cluster.label), + randomUUID(), + ); + } + + await db.transaction(async (trx) => { + await validateDatabaseFaces( + trx, + options, + validation.assignments, + ); + + await trx('face_cluster_runs') + .insert({ + id: runId, + user: options.user, + status: 'processing', + scope: options.scope, + embedding_count: + validation.totalFaces, + min_cluster_size: + options.minClusterSize, + min_samples: + options.minSamples, + metric: options.metric, + selection_method: + options.selectionMethod, + engine: 'sklearn', + engine_version: null, + cluster_count: null, + clustered_faces: null, + noise_faces: null, + noise_percent: null, + last_error: null, + started_at: now, + completed_at: null, + created_at: now, + updated_at: now, + }); + + const clusterRows = + result.clusters.map( + (cluster) => ({ + id: clusterIdByLabel.get( + Number(cluster.label), + ), + run_id: runId, + label: Number(cluster.label), + size: Number(cluster.size), + min_samples: + options.minSamples, + min_cluster_size: + options.minClusterSize, + person_id: null, + created_at: now, + updated_at: now, + }), + ); + + if (clusterRows.length > 0) { + await trx('face_clusters') + .insert(clusterRows); + } + + const membershipRows = + validation.assignments.map( + (assignment) => ({ + id: randomUUID(), + run_id: runId, + cluster_id: + assignment.noiseFlag === 1 + ? null + : clusterIdByLabel.get( + assignment.label, + ), + face_id: assignment.faceId, + label: assignment.label, + probability: + assignment.probability, + noise_flag: + assignment.noiseFlag, + review_status: 'suggested', + reviewed_by: null, + reviewed_at: null, + created_at: now, + updated_at: now, + }), + ); + + await trx( + 'face_cluster_memberships', + ).insert(membershipRows); + + if (options.scope === 'all') { + await trx('faces as f') + .whereIn( + 'f.id', + function selectUserFaceIds() { + this.select('f2.id') + .from('faces as f2') + .join( + 'photos as p2', + 'p2.id', + 'f2.photo_id', + ) + .where( + 'p2.user', + options.user, + ); + }, + ) + .update({ + cluster_id: null, + status: 'detected', + updated_at: now, + }); + } + + for ( + const assignment + of validation.assignments + ) { + if (assignment.noiseFlag === 1) { + continue; + } + + const clusterId = + clusterIdByLabel.get( + assignment.label, + ); + + if (!clusterId) { + throw new Error( + 'Cluster ID mancante per label ' + + assignment.label, + ); + } + + const updated = await trx('faces') + .where({ + id: assignment.faceId, + }) + .update({ + cluster_id: clusterId, + status: 'clustered', + updated_at: now, + }); + + if (updated !== 1) { + throw new Error( + 'Impossibile aggiornare il volto ' + + assignment.faceId, + ); + } + } + + await trx('face_cluster_runs') + .where({ + id: runId, + }) + .update({ + status: 'done', + cluster_count: + validation.clusterCount, + clustered_faces: + validation.clusteredFaces, + noise_faces: + validation.noiseFaces, + noise_percent: + Number( + result.summary.noise_percent, + ), + completed_at: now, + updated_at: now, + }); + }); + + return { + run_id: runId, + cluster_count: + validation.clusterCount, + clustered_faces: + validation.clusteredFaces, + noise_faces: + validation.noiseFaces, + }; +} + + +function printSummary( + result, + validation, + options, +) { + console.log({ + mode: options.persist + ? 'persist' + : 'dry-run', + user: options.user, + parameters: result.parameters, + summary: result.summary, + validated: { + total_faces: + validation.totalFaces, + cluster_count: + validation.clusterCount, + clustered_faces: + validation.clusteredFaces, + noise_faces: + validation.noiseFaces, + unique_memberships: + validation.assignments.length, + }, + }); +} + + +async function main() { + const options = parseArguments( + process.argv.slice(2), + ); + + if (options.help) { + printHelp(); + return; + } + + const result = + await runPythonClustering(options); + + const validation = + validateResult( + result, + options, + ); + + printSummary( + result, + validation, + options, + ); + + if (!options.persist) { + console.log( + '[faceClustering] dry-run completato. ' + + 'Database non modificato.', + ); + + return; + } + + const persisted = await persistResult( + result, + validation, + options, + ); + + console.log( + '[faceClustering] run persistita:', + persisted, + ); +} + + +main() + .catch((error) => { + console.error( + '[faceClustering] errore:', + error.stack + || error.message + || error, + ); + + process.exitCode = 1; + }) + .finally(async () => { + await db.destroy(); + }); diff --git a/tools/runFaceClusteringScheduler.js b/tools/runFaceClusteringScheduler.js new file mode 100644 index 0000000..61a26b1 --- /dev/null +++ b/tools/runFaceClusteringScheduler.js @@ -0,0 +1,188 @@ +// tools/runFaceClusteringScheduler.js +const db = require('../db/knex'); +const { spawn } = require('child_process'); + +let running = false; + +async function getUsersNeedingClustering() { + + const result = await db.raw(` + SELECT + p.user + FROM faces f + JOIN photos p + ON p.id = f.photo_id + LEFT JOIN ( + SELECT + user, + MAX(completed_at) AS last_cluster + FROM face_cluster_runs + GROUP BY user + ) r + ON r.user = p.user + GROUP BY p.user + HAVING + MAX(f.created_at) + > + COALESCE( + MAX(r.last_cluster), + '1970-01-01' + ) + `); + + return Array.isArray(result) + ? result + : result?.rows || []; +} + +function clusterUser(user) { + return new Promise((resolve, reject) => { + + console.log( + '[clusterScheduler] start:', + user, + ); + + const child = spawn( + process.execPath, + [ + 'tools/runFaceClustering.js', + '--user', + user, + '--persist', + ], + { + stdio: 'inherit', + }, + ); + + child.on('exit', (code) => { + + if (code === 0) { + console.log( + '[clusterScheduler] done:', + user, + ); + + resolve(); + return; + } + + reject( + new Error( + `Clustering exit code ${code}`, + ), + ); + }); + + child.on('error', reject); + }); +} + +async function clusterUserIfNeeded(user) { + + const result = await db.raw( + ` + SELECT + MAX(f.created_at) AS last_face, + ( + SELECT MAX(completed_at) + FROM face_cluster_runs + WHERE user = ? + ) AS last_cluster + FROM faces f + JOIN photos p + ON p.id = f.photo_id + WHERE p.user = ? + `, + [user, user], + ); + + const rows = + Array.isArray(result) + ? result + : result?.rows || []; + + const row = rows[0]; + + if (!row) { + return false; + } + + const lastFace = + row.last_face; + + const lastCluster = + row.last_cluster; + + if (!lastFace) { + return false; + } + + if ( + lastCluster + && new Date(lastFace) + <= new Date(lastCluster) + ) { + + console.log( + '[clusterScheduler] already up to date:', + user, + ); + + return false; + } + + console.log( + '[clusterScheduler] clustering required:', + user, + ); + + await clusterUser(user); + + return true; +} + +async function runCycle() { + + if (running) { + return; + } + + running = true; + + try { + + const users = + await getUsersNeedingClustering(); + + if (!users.length) { + + console.log( + '[clusterScheduler] no users to cluster', + ); + + return; + } + + for (const row of users) { + + const user = + row.user || row.USER; + + if (!user) { + continue; + } + + await clusterUser(user); + } + + } finally { + running = false; + } +} + +module.exports = { + runCycle, + clusterUserIfNeeded, +}; diff --git a/tools/runFaceKnnShadow.js b/tools/runFaceKnnShadow.js new file mode 100644 index 0000000..2a872a1 --- /dev/null +++ b/tools/runFaceKnnShadow.js @@ -0,0 +1,543 @@ +const fs = require('fs'); +const path = require('path'); + +const db = require('../db/knex'); + +const { + runFaceKnn, +} = require('./faceKnnRuntime'); + +function parseArguments(argv) { + const experimentIndex = + argv.indexOf('--experiment'); + + const outputIndex = + argv.indexOf('--output'); + + const backendIndex = + argv.indexOf('--backend'); + + return { + experimentPath: + experimentIndex >= 0 + ? path.resolve( + String( + argv[experimentIndex + 1] + || '', + ), + ) + : '', + + outputPath: + outputIndex >= 0 + ? path.resolve( + String( + argv[outputIndex + 1] + || '', + ), + ) + : '', + + backend: + backendIndex >= 0 + ? String( + argv[backendIndex + 1] + || '', + ) + : '', + + keepTemporaryFiles: + argv.includes( + '--keep-temporary-files', + ), + }; +} + +function mean(values) { + if (values.length === 0) { + return null; + } + + return ( + values.reduce( + (sum, value) => + sum + value, + 0, + ) + / values.length + ); +} + +function compareResults({ + experiment, + runtime, +}) { + const pythonQueries = + experiment + ?.evaluations + ?.greedy_gain + ?.queries; + + if ( + !Array.isArray(pythonQueries) + || pythonQueries.length === 0 + ) { + throw new Error( + 'Query greedy_gain mancanti', + ); + } + + const openclResults = + runtime.output.results; + + if ( + !Array.isArray(openclResults) + || openclResults.length + !== pythonQueries.length + ) { + throw new Error( + 'Numero risultati face_knn incoerente', + ); + } + + const threshold = + Number( + runtime.config.recognitionThreshold, + ); + + const tolerance = + Number( + runtime.config.scoreTolerance, + ); + + let top1Matches = 0; + let decisionMatches = 0; + let bestScoreMatches = 0; + let top3MeanMatches = 0; + let maximumScoreError = 0; + let maximumTop3MeanError = 0; + + const comparisons = []; + const mismatches = []; + + for ( + let queryIndex = 0; + queryIndex < pythonQueries.length; + queryIndex += 1 + ) { + const pythonQuery = + pythonQueries[queryIndex]; + + const nativeQuery = + openclResults[queryIndex]; + + const queryMetadata = + runtime.queryRows[queryIndex]; + + if ( + pythonQuery.face_id + !== queryMetadata.face_id + ) { + throw new Error( + 'Ordine query incoerente ' + + `all'indice ${queryIndex}`, + ); + } + + const nativeBestIndex = + Number(nativeQuery.best_index); + + const nativePrototype = + runtime.prototypeRows[ + nativeBestIndex + ]; + + if (!nativePrototype) { + throw new Error( + 'Indice prototype OpenCL ' + + `non valido: ${nativeBestIndex}`, + ); + } + + const pythonBestScore = + pythonQuery.best_similarity == null + ? null + : Number( + pythonQuery.best_similarity, + ); + + const nativeBestScore = + Number( + nativeQuery.best_score, + ); + + const pythonTop3Mean = + pythonQuery.top3_mean == null + ? null + : Number( + pythonQuery.top3_mean, + ); + + const nativeTop3Mean = + mean( + (nativeQuery.neighbors || []) + .slice(0, 3) + .map((neighbor) => + Number(neighbor.score)), + ); + + const pythonRecognized = + pythonBestScore != null + && Number.isFinite( + pythonBestScore, + ) + && pythonBestScore >= threshold; + + const nativeRecognized = + Boolean(nativeQuery.recognized); + + const prototypeMatch = + String( + pythonQuery + .best_prototype_face_id + || '', + ) + === nativePrototype.face_id; + + const decisionMatch = + pythonRecognized + === nativeRecognized; + + const scoreError = + pythonBestScore == null + ? null + : Math.abs( + pythonBestScore + - nativeBestScore, + ); + + const top3MeanError = + ( + pythonTop3Mean == null + || nativeTop3Mean == null + ) + ? null + : Math.abs( + pythonTop3Mean + - nativeTop3Mean, + ); + + const scoreMatch = + scoreError != null + && scoreError <= tolerance; + + const top3MeanMatch = + top3MeanError != null + && top3MeanError <= tolerance; + + if (prototypeMatch) { + top1Matches += 1; + } + + if (decisionMatch) { + decisionMatches += 1; + } + + if (scoreMatch) { + bestScoreMatches += 1; + } + + if (top3MeanMatch) { + top3MeanMatches += 1; + } + + if (scoreError != null) { + maximumScoreError = + Math.max( + maximumScoreError, + scoreError, + ); + } + + if (top3MeanError != null) { + maximumTop3MeanError = + Math.max( + maximumTop3MeanError, + top3MeanError, + ); + } + + const comparison = { + query_index: + queryIndex, + + face_id: + pythonQuery.face_id, + + photo_name: + pythonQuery.photo_name, + + is_prototype: + Boolean( + pythonQuery.is_prototype, + ), + + excluded_index: + nativeQuery.excluded_index, + + python_best_prototype_face_id: + pythonQuery + .best_prototype_face_id + || null, + + native_best_prototype_face_id: + nativePrototype.face_id, + + native_best_prototype_index: + nativeBestIndex, + + python_best_similarity: + pythonBestScore, + + native_best_similarity: + nativeBestScore, + + absolute_score_error: + scoreError, + + python_top3_mean: + pythonTop3Mean, + + native_top3_mean: + nativeTop3Mean, + + absolute_top3_mean_error: + top3MeanError, + + python_recognized: + pythonRecognized, + + native_recognized: + nativeRecognized, + + prototype_match: + prototypeMatch, + + decision_match: + decisionMatch, + + score_match: + scoreMatch, + + top3_mean_match: + top3MeanMatch, + }; + + comparisons.push(comparison); + + if ( + !prototypeMatch + || !decisionMatch + || !scoreMatch + || !top3MeanMatch + ) { + mismatches.push(comparison); + } + } + + return { + threshold, + tolerance, + query_count: + pythonQueries.length, + prototype_count: + runtime.prototypeRows.length, + top1_matches: + top1Matches, + decision_matches: + decisionMatches, + best_score_matches: + bestScoreMatches, + top3_mean_matches: + top3MeanMatches, + maximum_score_error: + maximumScoreError, + maximum_top3_mean_error: + maximumTop3MeanError, + mismatch_count: + mismatches.length, + mismatches, + comparisons, + }; +} + +async function main() { + const options = + parseArguments( + process.argv.slice(2), + ); + + if (!options.experimentPath) { + throw new Error( + 'Uso: node tools/runFaceKnnShadow.js ' + + '--experiment FILE ' + + '[--output FILE] ' + + '[--backend opencl|cpu]', + ); + } + + const experiment = + JSON.parse( + fs.readFileSync( + options.experimentPath, + 'utf8', + ), + ); + + const person = + experiment.person || {}; + + const pythonQueries = + experiment + ?.evaluations + ?.greedy_gain + ?.queries; + + if ( + !person.id + || !person.user + || !Array.isArray(pythonQueries) + ) { + throw new Error( + 'Esperimento incompleto', + ); + } + + const runtime = + await runFaceKnn({ + connection: db, + personId: person.id, + user: person.user, + queryFaceIds: + pythonQueries.map( + (query) => query.face_id, + ), + backend: + options.backend || undefined, + keepTemporaryFiles: + options.keepTemporaryFiles, + }); + + const comparison = + compareResults({ + experiment, + runtime, + }); + + const report = { + mode: 'shadow', + generated_at: + new Date().toISOString(), + experiment_path: + options.experimentPath, + database: + experiment.database || null, + person: { + id: person.id, + user: person.user, + name: person.name || null, + }, + backend: + runtime.output.backend, + device_name: + runtime.output.device_name, + elapsed_ms: + runtime.output.elapsed_ms, + query_count: + comparison.query_count, + prototype_count: + comparison.prototype_count, + threshold: + comparison.threshold, + tolerance: + comparison.tolerance, + top1_matches: + comparison.top1_matches, + decision_matches: + comparison.decision_matches, + best_score_matches: + comparison.best_score_matches, + top3_mean_matches: + comparison.top3_mean_matches, + maximum_score_error: + comparison.maximum_score_error, + maximum_top3_mean_error: + comparison.maximum_top3_mean_error, + mismatch_count: + comparison.mismatch_count, + mismatches: + comparison.mismatches, + comparisons: + comparison.comparisons, + prototypes: + runtime.prototypeRows, + native_stderr: + runtime.stderr, + temporary_directory: + runtime.temporaryDirectory, + }; + + const reportText = + `${JSON.stringify( + report, + null, + 2, + )}\n`; + + if (options.outputPath) { + fs.mkdirSync( + path.dirname( + options.outputPath, + ), + { + recursive: true, + }, + ); + + const temporaryOutput = + `${options.outputPath}.tmp-` + + process.pid; + + fs.writeFileSync( + temporaryOutput, + reportText, + 'utf8', + ); + + fs.renameSync( + temporaryOutput, + options.outputPath, + ); + } + + process.stdout.write(reportText); + + if (comparison.mismatch_count > 0) { + process.exitCode = 1; + } +} + +main() + .catch((error) => { + console.error( + '[faceKnnShadow] errore:', + error?.stack + || error?.message + || error, + ); + + process.exitCode = 1; + }) + .finally(async () => { + await db.destroy(); + }); diff --git a/tools/runFaceScanDaemon.js b/tools/runFaceScanDaemon.js new file mode 100644 index 0000000..a1f270d --- /dev/null +++ b/tools/runFaceScanDaemon.js @@ -0,0 +1,452 @@ +const fs = require('fs'); +const path = require('path'); + +const db = require('../db/knex'); + +const { + processNextJob, + resetStaleProcessingJobs, +} = require('../api_v1/scanner/faceScanWorker'); + +const PROJECT_ROOT = + path.resolve(__dirname, '..'); + +const DEFAULT_LOCK_PATH = + path.join(PROJECT_ROOT, 'face-scan-daemon.lock'); + +function parsePositiveInteger( + value, + optionName, +) { + const number = Number(value); + + if ( + !Number.isInteger(number) + || number <= 0 + ) { + throw new Error( + `${optionName} deve essere un intero positivo`, + ); + } + + return number; +} + +function parseArguments(argv) { + const options = { + pollMs: 2000, + resetStaleMinutes: 30, + staleCheckMs: 60000, + lockPath: + process.env.FACE_SCAN_DAEMON_LOCK + || DEFAULT_LOCK_PATH, + }; + + for ( + let index = 0; + index < argv.length; + index += 1 + ) { + const argument = argv[index]; + const value = argv[index + 1]; + + if (argument === '--poll-ms') { + if (value == null) { + throw new Error( + 'Valore mancante dopo --poll-ms', + ); + } + + options.pollMs = parsePositiveInteger( + value, + '--poll-ms', + ); + + index += 1; + continue; + } + + if ( + argument + === '--reset-stale-minutes' + ) { + if (value == null) { + throw new Error( + 'Valore mancante dopo ' + + '--reset-stale-minutes', + ); + } + + options.resetStaleMinutes = + parsePositiveInteger( + value, + '--reset-stale-minutes', + ); + + index += 1; + continue; + } + + if (argument === '--stale-check-ms') { + if (value == null) { + throw new Error( + 'Valore mancante dopo ' + + '--stale-check-ms', + ); + } + + options.staleCheckMs = + parsePositiveInteger( + value, + '--stale-check-ms', + ); + + index += 1; + continue; + } + + if (argument === '--lock-file') { + if (!value) { + throw new Error( + 'Valore mancante dopo --lock-file', + ); + } + + options.lockPath = path.resolve(value); + index += 1; + continue; + } + + throw new Error( + `Argomento sconosciuto: ${argument}`, + ); + } + + return options; +} + +function wait(milliseconds) { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} + +function isProcessAlive(pid) { + if ( + !Number.isInteger(pid) + || pid <= 0 + ) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code === 'EPERM'; + } +} + +function readLock(lockPath) { + try { + const text = fs.readFileSync( + lockPath, + 'utf8', + ); + + return JSON.parse(text); + } catch { + return null; + } +} + +function acquireLock(lockPath) { + const lockDirectory = + path.dirname(lockPath); + + fs.mkdirSync( + lockDirectory, + { recursive: true }, + ); + + try { + const descriptor = fs.openSync( + lockPath, + 'wx', + ); + + const lockData = { + pid: process.pid, + started_at: + new Date().toISOString(), + project_root: PROJECT_ROOT, + }; + + fs.writeFileSync( + descriptor, + JSON.stringify(lockData, null, 2) + + '\n', + 'utf8', + ); + + fs.closeSync(descriptor); + + return; + } catch (error) { + if (error.code !== 'EEXIST') { + throw error; + } + } + + const existingLock = readLock(lockPath); + const existingPid = + Number(existingLock?.pid); + + if (isProcessAlive(existingPid)) { + throw new Error( + `Daemon già attivo con PID ` + + `${existingPid}. Lock: ${lockPath}`, + ); + } + + console.warn( + '[faceScanDaemon] rimuovo lock obsoleto:', + { + lock_path: lockPath, + previous_pid: + existingLock?.pid ?? null, + }, + ); + + fs.unlinkSync(lockPath); + + acquireLock(lockPath); +} + +function releaseLock(lockPath) { + const lock = readLock(lockPath); + + if ( + lock + && Number(lock.pid) !== process.pid + ) { + console.warn( + '[faceScanDaemon] lock appartenente ' + + 'a un altro processo, non rimosso:', + lockPath, + ); + + return; + } + + try { + fs.unlinkSync(lockPath); + } catch (error) { + if (error.code !== 'ENOENT') { + console.warn( + '[faceScanDaemon] impossibile ' + + 'rimuovere il lock:', + error.message, + ); + } + } +} + +async function getQueueStatus() { + const rows = await db('face_scan_jobs') + .select('status') + .count({ count: '*' }) + .groupBy('status'); + + const status = { + pending: 0, + processing: 0, + done: 0, + error: 0, + }; + + for (const row of rows) { + status[row.status] = + Number(row.count || 0); + } + + return status; +} + +async function main() { + const options = parseArguments( + process.argv.slice(2), + ); + + let stopRequested = false; + let lastStaleCheck = 0; + + acquireLock(options.lockPath); + + const requestStop = (signal) => { + if (stopRequested) { + console.log( + `[faceScanDaemon] ${signal}: ` + + 'arresto già richiesto', + ); + + return; + } + + stopRequested = true; + + console.log( + `[faceScanDaemon] ${signal}: ` + + 'arresto dopo il job corrente', + ); + }; + + process.on( + 'SIGINT', + () => requestStop('SIGINT'), + ); + + process.on( + 'SIGTERM', + () => requestStop('SIGTERM'), + ); + + const initialReset = + await resetStaleProcessingJobs( + options.resetStaleMinutes, + ); + + lastStaleCheck = Date.now(); + + console.log( + '[faceScanDaemon] avviato', + { + pid: process.pid, + project_root: PROJECT_ROOT, + poll_ms: options.pollMs, + stale_check_ms: + options.staleCheckMs, + reset_stale_minutes: + options.resetStaleMinutes, + stale_jobs_reset: initialReset, + lock_path: options.lockPath, + queue: await getQueueStatus(), + }, + ); + + let processed = 0; + let succeeded = 0; + let failed = 0; + let rescheduled = 0; + + while (!stopRequested) { + const now = Date.now(); + + if ( + now - lastStaleCheck + >= options.staleCheckMs + ) { + const resetJobs = + await resetStaleProcessingJobs( + options.resetStaleMinutes, + ); + + lastStaleCheck = now; + + if (resetJobs > 0) { + console.log( + '[faceScanDaemon] job obsoleti ' + + 'ripristinati:', + resetJobs, + ); + } + } + + let result = null; + + try { + result = await processNextJob(); + } catch (error) { + const message = String( + error?.message || error + ); + + if ( + message.includes('database is locked') + ) { + console.warn( + '[daemon] SQLite locked, retrying in 1000ms' + ); + + await new Promise((resolve) => + setTimeout(resolve, 1000) + ); + + continue; + } + + throw error; + } + + if (result) { + processed += 1; + + if (result.status === 'done') { + succeeded += 1; + } else if ( + result.status === 'error' + ) { + failed += 1; + } else if ( + result.status === 'pending' + ) { + rescheduled += 1; + } + + continue; + } + + await wait(options.pollMs); + } + + console.log( + '[faceScanDaemon] terminato', + { + pid: process.pid, + jobs_processed: processed, + jobs_succeeded: succeeded, + jobs_failed: failed, + jobs_rescheduled: rescheduled, + queue: await getQueueStatus(), + }, + ); +} + +let lockPath = null; + +(async () => { + const options = parseArguments( + process.argv.slice(2), + ); + + lockPath = options.lockPath; + + await main(); +})() + .catch((error) => { + console.error( + '[faceScanDaemon] errore fatale:', + error.stack + || error.message + || error, + ); + + process.exitCode = 1; + }) + .finally(async () => { + if (lockPath) { + releaseLock(lockPath); + } + + await db.destroy(); + }); diff --git a/tools/runFaceScanWorker.js b/tools/runFaceScanWorker.js new file mode 100644 index 0000000..6c472ee --- /dev/null +++ b/tools/runFaceScanWorker.js @@ -0,0 +1,141 @@ +const db = require('../db/knex'); + +const { + runWorker, + resetStaleProcessingJobs, +} = require('../api_v1/scanner/faceScanWorker'); + +function parseArguments(argv) { + const options = { + maxJobs: null, + resetStaleMinutes: 30, + }; + + for ( + let index = 0; + index < argv.length; + index += 1 + ) { + const argument = argv[index]; + + if (argument === '--max-jobs') { + const value = argv[index + 1]; + + if (value == null) { + throw new Error( + 'Valore mancante dopo --max-jobs', + ); + } + + options.maxJobs = Number(value); + index += 1; + continue; + } + + if (argument === '--reset-stale-minutes') { + const value = argv[index + 1]; + + if (value == null) { + throw new Error( + 'Valore mancante dopo --reset-stale-minutes', + ); + } + + options.resetStaleMinutes = Number(value); + index += 1; + continue; + } + + throw new Error( + `Argomento sconosciuto: ${argument}`, + ); + } + + if ( + options.maxJobs !== null + && ( + !Number.isInteger(options.maxJobs) + || options.maxJobs <= 0 + ) + ) { + throw new Error( + '--max-jobs deve essere un intero positivo', + ); + } + + if ( + !Number.isFinite(options.resetStaleMinutes) + || options.resetStaleMinutes <= 0 + ) { + throw new Error( + '--reset-stale-minutes deve essere positivo', + ); + } + + return options; +} + +async function main() { + const options = parseArguments( + process.argv.slice(2), + ); + + let stopRequested = false; + + const requestStop = (signal) => { + if (stopRequested) { + return; + } + + stopRequested = true; + + console.log( + `[runFaceScanWorker] ${signal}: ` + + 'arresto dopo il job corrente', + ); + }; + + process.on( + 'SIGINT', + () => requestStop('SIGINT'), + ); + + process.on( + 'SIGTERM', + () => requestStop('SIGTERM'), + ); + + const resetJobs = + await resetStaleProcessingJobs( + options.resetStaleMinutes, + ); + + console.log({ + stale_jobs_reset: resetJobs, + max_jobs: options.maxJobs, + }); + + const result = await runWorker({ + maxJobs: options.maxJobs, + shouldStop: () => stopRequested, + }); + + console.log( + JSON.stringify(result, null, 2), + ); +} + +main() + .catch((error) => { + console.error( + '[runFaceScanWorker] errore:', + error.stack + || error.message + || error, + ); + + process.exitCode = 1; + }) + .finally(async () => { + await db.destroy(); + }); diff --git a/tools/runGeocodeDaemon.js b/tools/runGeocodeDaemon.js new file mode 100644 index 0000000..8daf432 --- /dev/null +++ b/tools/runGeocodeDaemon.js @@ -0,0 +1,452 @@ +const fs = require('fs'); +const path = require('path'); + +const db = require('../db/knex'); + +const { + processNextJob, + resetStaleProcessingJobs, +} = require('../api_v1/scanner/geocodeWorker'); + +const PROJECT_ROOT = + path.resolve(__dirname, '..'); + +const DEFAULT_LOCK_PATH = + path.join(PROJECT_ROOT, 'geocode-daemon.lock'); + +function parsePositiveInteger( + value, + optionName, +) { + const number = Number(value); + + if ( + !Number.isInteger(number) + || number <= 0 + ) { + throw new Error( + `${optionName} deve essere un intero positivo`, + ); + } + + return number; +} + +function parseArguments(argv) { + const options = { + pollMs: 2000, + resetStaleMinutes: 30, + staleCheckMs: 60000, + lockPath: + process.env.GEOCODE_DAEMON_LOCK + || DEFAULT_LOCK_PATH, + }; + + for ( + let index = 0; + index < argv.length; + index += 1 + ) { + const argument = argv[index]; + const value = argv[index + 1]; + + if (argument === '--poll-ms') { + if (value == null) { + throw new Error( + 'Valore mancante dopo --poll-ms', + ); + } + + options.pollMs = parsePositiveInteger( + value, + '--poll-ms', + ); + + index += 1; + continue; + } + + if ( + argument + === '--reset-stale-minutes' + ) { + if (value == null) { + throw new Error( + 'Valore mancante dopo ' + + '--reset-stale-minutes', + ); + } + + options.resetStaleMinutes = + parsePositiveInteger( + value, + '--reset-stale-minutes', + ); + + index += 1; + continue; + } + + if (argument === '--stale-check-ms') { + if (value == null) { + throw new Error( + 'Valore mancante dopo ' + + '--stale-check-ms', + ); + } + + options.staleCheckMs = + parsePositiveInteger( + value, + '--stale-check-ms', + ); + + index += 1; + continue; + } + + if (argument === '--lock-file') { + if (!value) { + throw new Error( + 'Valore mancante dopo --lock-file', + ); + } + + options.lockPath = path.resolve(value); + index += 1; + continue; + } + + throw new Error( + `Argomento sconosciuto: ${argument}`, + ); + } + + return options; +} + +function wait(milliseconds) { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} + +function isProcessAlive(pid) { + if ( + !Number.isInteger(pid) + || pid <= 0 + ) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code === 'EPERM'; + } +} + +function readLock(lockPath) { + try { + const text = fs.readFileSync( + lockPath, + 'utf8', + ); + + return JSON.parse(text); + } catch { + return null; + } +} + +function acquireLock(lockPath) { + const lockDirectory = + path.dirname(lockPath); + + fs.mkdirSync( + lockDirectory, + { recursive: true }, + ); + + try { + const descriptor = fs.openSync( + lockPath, + 'wx', + ); + + const lockData = { + pid: process.pid, + started_at: + new Date().toISOString(), + project_root: PROJECT_ROOT, + }; + + fs.writeFileSync( + descriptor, + JSON.stringify(lockData, null, 2) + + '\n', + 'utf8', + ); + + fs.closeSync(descriptor); + + return; + } catch (error) { + if (error.code !== 'EEXIST') { + throw error; + } + } + + const existingLock = readLock(lockPath); + const existingPid = + Number(existingLock?.pid); + + if (isProcessAlive(existingPid)) { + throw new Error( + `Daemon già attivo con PID ` + + `${existingPid}. Lock: ${lockPath}`, + ); + } + + console.warn( + '[geocodeDaemon] rimuovo lock obsoleto:', + { + lock_path: lockPath, + previous_pid: + existingLock?.pid ?? null, + }, + ); + + fs.unlinkSync(lockPath); + + acquireLock(lockPath); +} + +function releaseLock(lockPath) { + const lock = readLock(lockPath); + + if ( + lock + && Number(lock.pid) !== process.pid + ) { + console.warn( + '[geocodeDaemon] lock appartenente ' + + 'a un altro processo, non rimosso:', + lockPath, + ); + + return; + } + + try { + fs.unlinkSync(lockPath); + } catch (error) { + if (error.code !== 'ENOENT') { + console.warn( + '[geocodeDaemon] impossibile ' + + 'rimuovere il lock:', + error.message, + ); + } + } +} + +async function getQueueStatus() { + const rows = await db('geocode_jobs') + .select('status') + .count({ count: '*' }) + .groupBy('status'); + + const status = { + pending: 0, + processing: 0, + done: 0, + error: 0, + }; + + for (const row of rows) { + status[row.status] = + Number(row.count || 0); + } + + return status; +} + +async function main() { + const options = parseArguments( + process.argv.slice(2), + ); + + let stopRequested = false; + let lastStaleCheck = 0; + + acquireLock(options.lockPath); + + const requestStop = (signal) => { + if (stopRequested) { + console.log( + `[geocodeDaemon] ${signal}: ` + + 'arresto già richiesto', + ); + + return; + } + + stopRequested = true; + + console.log( + `[geocodeDaemon] ${signal}: ` + + 'arresto dopo il job corrente', + ); + }; + + process.on( + 'SIGINT', + () => requestStop('SIGINT'), + ); + + process.on( + 'SIGTERM', + () => requestStop('SIGTERM'), + ); + + const initialReset = + await resetStaleProcessingJobs( + options.resetStaleMinutes, + ); + + lastStaleCheck = Date.now(); + + console.log( + '[geocodeDaemon] avviato', + { + pid: process.pid, + project_root: PROJECT_ROOT, + poll_ms: options.pollMs, + stale_check_ms: + options.staleCheckMs, + reset_stale_minutes: + options.resetStaleMinutes, + stale_jobs_reset: initialReset, + lock_path: options.lockPath, + queue: await getQueueStatus(), + }, + ); + + let processed = 0; + let succeeded = 0; + let failed = 0; + let rescheduled = 0; + + while (!stopRequested) { + const now = Date.now(); + + if ( + now - lastStaleCheck + >= options.staleCheckMs + ) { + const resetJobs = + await resetStaleProcessingJobs( + options.resetStaleMinutes, + ); + + lastStaleCheck = now; + + if (resetJobs > 0) { + console.log( + '[geocodeDaemon] job obsoleti ' + + 'ripristinati:', + resetJobs, + ); + } + } + + let result = null; + + try { + result = await processNextJob(); + } catch (error) { + const message = String( + error?.message || error + ); + + if ( + message.includes('database is locked') + ) { + console.warn( + '[daemon] SQLite locked, retrying in 1000ms' + ); + + await new Promise((resolve) => + setTimeout(resolve, 1000) + ); + + continue; + } + + throw error; + } + + if (result) { + processed += 1; + + if (result.status === 'done') { + succeeded += 1; + } else if ( + result.status === 'error' + ) { + failed += 1; + } else if ( + result.status === 'pending' + ) { + rescheduled += 1; + } + + continue; + } + + await wait(options.pollMs); + } + + console.log( + '[geocodeDaemon] terminato', + { + pid: process.pid, + jobs_processed: processed, + jobs_succeeded: succeeded, + jobs_failed: failed, + jobs_rescheduled: rescheduled, + queue: await getQueueStatus(), + }, + ); +} + +let lockPath = null; + +(async () => { + const options = parseArguments( + process.argv.slice(2), + ); + + lockPath = options.lockPath; + + await main(); +})() + .catch((error) => { + console.error( + '[geocodeDaemon] errore fatale:', + error.stack + || error.message + || error, + ); + + process.exitCode = 1; + }) + .finally(async () => { + if (lockPath) { + releaseLock(lockPath); + } + + await db.destroy(); + }); diff --git a/tools/runGeocodeWorker.js b/tools/runGeocodeWorker.js new file mode 100644 index 0000000..27de641 --- /dev/null +++ b/tools/runGeocodeWorker.js @@ -0,0 +1,153 @@ +const db = require('../db/knex'); + +const { + runWorker, + resetStaleProcessingJobs, +} = require( + '../api_v1/scanner/geocodeWorker' +); + +function parseArguments(argv) { + const options = { + maxJobs: null, + resetStaleMinutes: 30, + }; + + for ( + let index = 0; + index < argv.length; + index += 1 + ) { + const argument = argv[index]; + + if (argument === '--max-jobs') { + const value = argv[index + 1]; + + if (value == null) { + throw new Error( + 'Valore mancante dopo --max-jobs', + ); + } + + options.maxJobs = Number(value); + index += 1; + continue; + } + + if ( + argument + === '--reset-stale-minutes' + ) { + const value = argv[index + 1]; + + if (value == null) { + throw new Error( + 'Valore mancante dopo ' + + '--reset-stale-minutes', + ); + } + + options.resetStaleMinutes = + Number(value); + + index += 1; + continue; + } + + throw new Error( + `Argomento sconosciuto: ${argument}`, + ); + } + + if ( + options.maxJobs !== null + && ( + !Number.isInteger(options.maxJobs) + || options.maxJobs <= 0 + ) + ) { + throw new Error( + '--max-jobs deve essere ' + + 'un intero positivo', + ); + } + + if ( + !Number.isFinite( + options.resetStaleMinutes + ) + || options.resetStaleMinutes <= 0 + ) { + throw new Error( + '--reset-stale-minutes ' + + 'deve essere positivo', + ); + } + + return options; +} + +async function main() { + const options = parseArguments( + process.argv.slice(2), + ); + + let stopRequested = false; + + const requestStop = (signal) => { + if (stopRequested) { + return; + } + + stopRequested = true; + + console.log( + `[runGeocodeWorker] ${signal}: ` + + 'arresto dopo il job corrente', + ); + }; + + process.on( + 'SIGINT', + () => requestStop('SIGINT'), + ); + + process.on( + 'SIGTERM', + () => requestStop('SIGTERM'), + ); + + const resetJobs = + await resetStaleProcessingJobs( + options.resetStaleMinutes, + ); + + console.log({ + stale_jobs_reset: resetJobs, + max_jobs: options.maxJobs, + }); + + const result = await runWorker({ + maxJobs: options.maxJobs, + shouldStop: () => stopRequested, + }); + + console.log( + JSON.stringify(result, null, 2), + ); +} + +main() + .catch((error) => { + console.error( + '[runGeocodeWorker] errore:', + error.stack + || error.message + || error, + ); + + process.exitCode = 1; + }) + .finally(async () => { + await db.destroy(); + }); diff --git a/tools/runPhotoIngestDaemon.js b/tools/runPhotoIngestDaemon.js new file mode 100644 index 0000000..e5ebb9f --- /dev/null +++ b/tools/runPhotoIngestDaemon.js @@ -0,0 +1,280 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const db = require('../db/knex'); + +const { + ensurePhotoIngestQueue, + resetStalePhotoIngestJobs, + getPhotoIngestQueueCounts, +} = require('../api_v1/scanner/photoIngestQueue'); + +const { runOneJob } = require('../api_v1/scanner/photoIngestWorker'); + +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const DEFAULT_LOCK_PATH = path.join( + PROJECT_ROOT, + 'photo-ingest-daemon.lock' +); + +function positiveInteger(value, option) { + const number = Number(value); + + if (!Number.isInteger(number) || number <= 0) { + throw new Error(`${option} deve essere un intero positivo`); + } + + return number; +} + +function parseArguments(argv) { + const options = { + pollMs: 2000, + resetStaleMinutes: 30, + staleCheckMs: 60000, + lockPath: + process.env.PHOTO_INGEST_DAEMON_LOCK || DEFAULT_LOCK_PATH, + help: false, + dryRun: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + + if (arg === '--help' || arg === '-h') { + options.help = true; + continue; + } + + if (arg === '--dry-run') { + options.dryRun = true; + continue; + } + + const names = { + '--poll-ms': 'pollMs', + '--reset-stale-minutes': 'resetStaleMinutes', + '--stale-check-ms': 'staleCheckMs', + '--lock-file': 'lockPath', + }; + + const key = names[arg]; + + if (!key) { + throw new Error(`Argomento sconosciuto: ${arg}`); + } + + const value = argv[i + 1]; + + if (value == null || value.startsWith('--')) { + throw new Error(`Valore mancante dopo ${arg}`); + } + + options[key] = key === 'lockPath' + ? path.resolve(value) + : positiveInteger(value, arg); + + i += 1; + } + + return options; +} + +function isProcessAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code === 'EPERM'; + } +} + +function readLock(lockPath) { + try { + return JSON.parse(fs.readFileSync(lockPath, 'utf8')); + } catch { + return null; + } +} + +function acquireLock(lockPath) { + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + + try { + const fd = fs.openSync(lockPath, 'wx'); + + try { + fs.writeFileSync(fd, JSON.stringify({ + pid: process.pid, + started_at: new Date().toISOString(), + project_root: PROJECT_ROOT, + }, null, 2) + '\n', 'utf8'); + } finally { + fs.closeSync(fd); + } + + return; + } catch (error) { + if (error.code !== 'EEXIST') throw error; + } + + const existing = readLock(lockPath); + const pid = Number(existing?.pid); + + if (isProcessAlive(pid)) { + throw new Error( + `Daemon già attivo con PID ${pid}. Lock: ${lockPath}` + ); + } + + // Non cancellare automaticamente un lock illeggibile: + // potrebbe appartenere a un processo in fase di avvio. + if (!existing || !Number.isInteger(pid) || pid <= 0) { + throw new Error( + `Lock esistente non verificabile: ${lockPath}. ` + + 'Verificarlo manualmente prima di rimuoverlo.' + ); + } + + fs.unlinkSync(lockPath); + acquireLock(lockPath); +} + +function releaseLock(lockPath) { + const lock = readLock(lockPath); + + if (Number(lock?.pid) !== process.pid) return; + + try { + fs.unlinkSync(lockPath); + } catch (error) { + if (error.code !== 'ENOENT') { + console.error('[PhotoIngestDaemon] Errore rimozione lock:', error.message); + } + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function main(options) { + if (options.help) { + console.log( + 'Uso: node tools/runPhotoIngestDaemon.js [opzioni]\n' + + ' --dry-run Test senza elaborare o recuperare job\n' + + ' --poll-ms N Attesa tra controlli (default 2000)\n' + + ' --reset-stale-minutes N Età job bloccati (default 30)\n' + + ' --stale-check-ms N Intervallo recupero (default 60000)\n' + + ' --lock-file PERCORSO Percorso lock personalizzato' + ); + return; + } + + let stopping = false; + let lockAcquired = false; + let lastStaleCheck = 0; + + const requestStop = signal => { + if (!stopping) { + console.log(`[PhotoIngestDaemon] Ricevuto ${signal}; arresto richiesto.`); + stopping = true; + } + }; + + const onSigint = () => requestStop('SIGINT'); + const onSigterm = () => requestStop('SIGTERM'); + + process.on('SIGINT', onSigint); + process.on('SIGTERM', onSigterm); + + try { + acquireLock(options.lockPath); + lockAcquired = true; + + await ensurePhotoIngestQueue(db); + + // Recupero iniziale eseguito soltanto dopo l'acquisizione del lock. + const recovered = options.dryRun + ? 0 + : await resetStalePhotoIngestJobs( + db, + options.resetStaleMinutes + ); + + lastStaleCheck = Date.now(); + + console.log('[PhotoIngestDaemon] Avviato.', { + pid: process.pid, + pollMs: options.pollMs, + staleCheckMs: options.staleCheckMs, + dryRun: options.dryRun, + recovered, + queue: await getPhotoIngestQueueCounts(db), + }); + + while (!stopping) { + try { + // Durante un job non eseguiamo un recupero concorrente. + if (options.dryRun) { + await sleep(options.pollMs); + continue; + } + + const result = await runOneJob(db); + + if (result.error) { + console.error('[PhotoIngestDaemon] Job fallito:', result.error); + } + + if (!result.processed && !stopping) { + await sleep(options.pollMs); + } + + if ( + !stopping && + Date.now() - lastStaleCheck >= options.staleCheckMs + ) { + const count = await resetStalePhotoIngestJobs( + db, + options.resetStaleMinutes + ); + + lastStaleCheck = Date.now(); + + if (count) { + console.warn( + `[PhotoIngestDaemon] Recuperati ${count} job bloccati.` + ); + } + } + } catch (error) { + console.error('[PhotoIngestDaemon] Errore ciclo:', error); + if (!stopping) await sleep(options.pollMs); + } + } + } finally { + process.removeListener('SIGINT', onSigint); + process.removeListener('SIGTERM', onSigterm); + + if (lockAcquired) releaseLock(options.lockPath); + } +} + +(async () => { + let options; + + try { + options = parseArguments(process.argv.slice(2)); + await main(options); + } catch (error) { + console.error('[PhotoIngestDaemon] Errore fatale:', error.message); + process.exitCode = 1; + } finally { + await db.destroy(); + } +})(); diff --git a/tools/runPhotoIngestWorker.js b/tools/runPhotoIngestWorker.js new file mode 100644 index 0000000..73d5381 --- /dev/null +++ b/tools/runPhotoIngestWorker.js @@ -0,0 +1,26 @@ +'use strict'; + +const db = require('../db/knex'); +const { runOneJob } = require('../api_v1/scanner/photoIngestWorker'); +const { + ensurePhotoIngestQueue, +} = require('../api_v1/scanner/photoIngestQueue'); + +async function main() { + await ensurePhotoIngestQueue(db); + const result = await runOneJob(db); + console.log('[PhotoIngest] Risultato:', result); + + if (result.error) { + process.exitCode = 1; + } +} + +main() + .catch(err => { + console.error('[PhotoIngest] Errore fatale:', err); + process.exitCode = 1; + }) + .finally(async () => { + await db.destroy(); + }); diff --git a/tools/runPrototypeDaemon.js b/tools/runPrototypeDaemon.js new file mode 100755 index 0000000..daa4b0c --- /dev/null +++ b/tools/runPrototypeDaemon.js @@ -0,0 +1,467 @@ +const fs = require('fs'); +const path = require('path'); +const { + spawn, +} = require('child_process'); + +const db = require('../db/knex'); + +const LOCK_PATH = path.resolve( + 'prototype-worker-daemon.lock', +); + + +function parsePositiveInteger( + value, + fallback, +) { + const parsed = Number.parseInt( + String(value || ''), + 10, + ); + + if ( + !Number.isFinite(parsed) + || parsed <= 0 + ) { + return fallback; + } + + return parsed; +} + + +function parseArguments(argv) { + const intervalIndex = + argv.indexOf('--interval-ms'); + + const staleIndex = + argv.indexOf('--stale-minutes'); + + return { + once: + argv.includes('--once'), + + intervalMs: + intervalIndex >= 0 + ? parsePositiveInteger( + argv[intervalIndex + 1], + 5000, + ) + : 5000, + + staleMinutes: + staleIndex >= 0 + ? parsePositiveInteger( + argv[staleIndex + 1], + 10, + ) + : 10, + }; +} + + +function isProcessRunning(pid) { + if ( + !Number.isInteger(pid) + || pid <= 0 + ) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code === 'EPERM'; + } +} + + +function readLockPid() { + try { + const content = fs.readFileSync( + LOCK_PATH, + 'utf8', + ); + + return Number.parseInt( + content.trim(), + 10, + ); + } catch (error) { + return null; + } +} + + +function acquireLock() { + try { + const descriptor = fs.openSync( + LOCK_PATH, + 'wx', + ); + + fs.writeFileSync( + descriptor, + String(process.pid), + 'utf8', + ); + + fs.closeSync(descriptor); + + return; + } catch (error) { + if (error.code !== 'EEXIST') { + throw error; + } + } + + const existingPid = + readLockPid(); + + if (isProcessRunning(existingPid)) { + throw new Error( + 'Daemon prototype gia attivo ' + + `con PID ${existingPid}`, + ); + } + + console.warn( + '[prototypeDaemon] ' + + 'lock obsoleto rimosso:', + LOCK_PATH, + ); + + fs.rmSync( + LOCK_PATH, + { + force: true, + }, + ); + + acquireLock(); +} + + +function releaseLock() { + const existingPid = + readLockPid(); + + if (existingPid === process.pid) { + fs.rmSync( + LOCK_PATH, + { + force: true, + }, + ); + } +} + + +function sleep(milliseconds) { + return new Promise( + (resolve) => { + setTimeout( + resolve, + milliseconds, + ); + }, + ); +} + + +async function countPendingJobs() { + const row = await db( + 'face_prototype_jobs', + ) + .where({ + status: 'pending', + }) + .count({ + count: '*', + }) + .first(); + + return Number( + row?.count || 0, + ); +} + + +async function recoverStaleJobs( + staleMinutes, +) { + const now = new Date(); + + const cutoff = new Date( + now.getTime() + - staleMinutes * 60 * 1000, + ).toISOString(); + + const updated = await db( + 'face_prototype_jobs', + ) + .where({ + status: 'processing', + }) + .whereNotNull('started_at') + .andWhere( + 'started_at', + '<', + cutoff, + ) + .update({ + status: 'pending', + + reason: + 'stale_processing_recovered', + + requested_at: + now.toISOString(), + + started_at: + null, + + completed_at: + null, + + last_error: + 'Job processing recuperato ' + + 'automaticamente dal daemon', + + updated_at: + now.toISOString(), + }); + + if (updated > 0) { + console.warn( + '[prototypeDaemon] ' + + `${updated} job processing ` + + 'recuperati', + ); + } + + return updated; +} + + +let activeChild = null; + + +function runWorker() { + return new Promise( + (resolve, reject) => { + const workerPath = path.resolve( + 'tools/runPrototypeWorker.js', + ); + + console.log( + '[prototypeDaemon] ' + + 'avvio worker persistente', + ); + + const child = spawn( + process.execPath, + [ + workerPath, + '--persist', + ], + { + cwd: process.cwd(), + + env: + process.env, + + stdio: + 'inherit', + }, + ); + + activeChild = child; + + child.once( + 'error', + (error) => { + activeChild = null; + reject(error); + }, + ); + + child.once( + 'exit', + (code, signal) => { + activeChild = null; + + if ( + code === 0 + && signal == null + ) { + resolve(); + return; + } + + reject( + new Error( + 'Worker terminato con ' + + `code=${code} ` + + `signal=${signal}`, + ), + ); + }, + ); + }, + ); +} + + +async function showStartupState( + options, +) { + const pending = + await countPendingJobs(); + + console.log({ + daemon: 'prototype-worker', + pid: process.pid, + lock_path: LOCK_PATH, + once: options.once, + interval_ms: + options.intervalMs, + stale_minutes: + options.staleMinutes, + pending_jobs: + pending, + }); +} + + +async function runLoop(options) { + let stopping = false; + + const requestStop = ( + signal, + ) => { + if (stopping) { + return; + } + + stopping = true; + + console.log( + '[prototypeDaemon] ' + + `ricevuto ${signal}, arresto`, + ); + + if (activeChild) { + activeChild.kill(signal); + } + }; + + process.on( + 'SIGINT', + () => requestStop('SIGINT'), + ); + + process.on( + 'SIGTERM', + () => requestStop('SIGTERM'), + ); + + await recoverStaleJobs( + options.staleMinutes, + ); + + await showStartupState( + options, + ); + + while (!stopping) { + const pending = + await countPendingJobs(); + + if (pending === 0) { + if (options.once) { + console.log( + '[prototypeDaemon] ' + + 'nessun job pending', + ); + + return; + } + + await sleep( + options.intervalMs, + ); + + continue; + } + + console.log( + '[prototypeDaemon] ' + + `${pending} job pending`, + ); + + try { + await runWorker(); + } catch (error) { + console.error( + '[prototypeDaemon] ' + + 'worker fallito:', + error.stack + || error.message + || error, + ); + + if (options.once) { + throw error; + } + + await sleep( + options.intervalMs, + ); + } + + if (options.once) { + return; + } + + /* + * Non attende dopo un successo: + * se ci sono altri job pending, + * li elabora immediatamente. + */ + } +} + + +async function main() { + const options = parseArguments( + process.argv.slice(2), + ); + + acquireLock(); + + try { + await runLoop(options); + } finally { + await db.destroy(); + releaseLock(); + } +} + + +main() + .catch((error) => { + console.error( + '[prototypeDaemon] errore:', + error.stack + || error.message + || error, + ); + + process.exitCode = 1; + }) + .finally(() => { + releaseLock(); + }); diff --git a/tools/runPrototypeWorker.js b/tools/runPrototypeWorker.js new file mode 100755 index 0000000..905bba8 --- /dev/null +++ b/tools/runPrototypeWorker.js @@ -0,0 +1,985 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const db = require('../db/knex'); + +function parseArguments(argv) { + const jobIndex = argv.indexOf('--job-id'); + + return { + persist: argv.includes('--persist'), + jobId: jobIndex >= 0 + ? String(argv[jobIndex + 1] || '').trim() + : '', + }; +} + +async function loadPendingJob(options) { + let query = db('face_prototype_jobs as job') + .join('persons as person', 'person.id', 'job.person_id') + .where({ 'job.status': 'pending' }); + + if (options.jobId) { + query = query.where({ 'job.id': options.jobId }); + } + + return query + .orderBy('job.requested_at', 'asc') + .first( + 'job.id', + 'job.user', + 'job.person_id', + 'job.reason', + 'job.requested_by', + 'job.requested_at', + 'job.attempts', + 'person.name as person_name', + ); +} + +async function loadCurrentPrototypes(connection, job) { + return connection('person_face_prototypes as prototype') + .join('faces as face', 'face.id', 'prototype.face_id') + .join('photos as photo', 'photo.id', 'face.photo_id') + .where({ + 'prototype.person_id': job.person_id, + 'prototype.user': job.user, + 'prototype.active': 1, + }) + .orderBy('prototype.selection_order', 'asc') + .select( + 'prototype.face_id', + 'prototype.selection_order', + 'prototype.selection_method', + 'prototype.locked', + 'prototype.source_type', + 'photo.name as photo_name', + ); +} + +async function loadReferenceExclusions(connection, job) { + return connection('person_face_reference_exclusions') + .where({ + person_id: job.person_id, + user: job.user, + }) + .select('face_id', 'reason_code', 'note') + .orderBy('face_id', 'asc'); +} + +async function computeInputSignature(connection, job) { + const faces = await connection('faces as face') + .join('photos as photo', 'photo.id', 'face.photo_id') + .where({ + 'face.person_id': job.person_id, + 'photo.user': job.user, + }) + .whereNotNull('face.embedding') + .select( + 'face.id', + 'face.updated_at', + 'face.cluster_id', + 'face.score', + ) + .orderBy('face.id', 'asc'); + + const exclusions = await loadReferenceExclusions(connection, job); + + return crypto + .createHash('sha256') + .update(JSON.stringify({ faces, exclusions })) + .digest('hex'); +} + +function runExperiment(job) { + const outputPath = path.join( + os.tmpdir(), + `prototype-worker-${job.id}.json`, + ); + + const result = spawnSync( + path.resolve('.venv-clustering/bin/python'), + [ + path.resolve('tools/prototype_experiment.py'), + '--database', + path.resolve('api_v1/database.sqlite'), + '--user', + job.user, + '--person', + job.person_name, + '--noise-prototypes', + '2', + '--greedy-additions', + '2', + '--min-detector-score', + '0.60', + '--output', + outputPath, + ], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + if (result.status !== 0) { + throw new Error( + 'Esperimento prototype fallito:\n' + + String(result.stderr || '') + + String(result.stdout || ''), + ); + } + + return { + outputPath, + stdout: String(result.stdout || ''), + data: JSON.parse(fs.readFileSync(outputPath, 'utf8')), + }; +} + +function loadFaceKnnConfig() { + const configPath = path.resolve( + 'native/face-knn/config.json', + ); + + if (!fs.existsSync(configPath)) { + return { + enabled: false, + mode: 'disabled', + }; + } + + const config = JSON.parse( + fs.readFileSync( + configPath, + 'utf8', + ), + ); + + return { + enabled: + config.enabled !== false, + + mode: + String( + config.mode || 'shadow', + ), + + backend: + String( + config.backend || 'opencl', + ), + + runtimeDirectory: + path.resolve( + String( + config.runtimeDirectory + || 'var/face-knn', + ), + ), + }; +} + +function runFaceKnnShadow( + job, + experimentPath, +) { + const config = + loadFaceKnnConfig(); + + if ( + !config.enabled + || config.mode !== 'shadow' + ) { + return { + executed: false, + reason: 'shadow-disabled', + }; + } + + fs.mkdirSync( + config.runtimeDirectory, + { + recursive: true, + }, + ); + + const outputPath = path.join( + config.runtimeDirectory, + `shadow-${job.id}.json`, + ); + + const result = spawnSync( + process.execPath, + [ + path.resolve( + 'tools/runFaceKnnShadow.js', + ), + '--experiment', + path.resolve( + experimentPath, + ), + '--output', + outputPath, + '--backend', + config.backend, + ], + { + cwd: path.resolve('.'), + encoding: 'utf8', + stdio: [ + 'ignore', + 'pipe', + 'pipe', + ], + maxBuffer: + 16 * 1024 * 1024, + }, + ); + + if (result.error) { + return { + executed: true, + ok: false, + backend: + config.backend, + output_path: + outputPath, + error: + String( + result.error.stack + || result.error.message + || result.error, + ), + }; + } + + if (result.status !== 0) { + return { + executed: true, + ok: false, + backend: + config.backend, + status: + result.status, + output_path: + outputPath, + stdout: + String( + result.stdout || '', + ).slice(0, 4000), + stderr: + String( + result.stderr || '', + ).slice(0, 4000), + }; + } + + if (!fs.existsSync(outputPath)) { + return { + executed: true, + ok: false, + backend: + config.backend, + status: + result.status, + output_path: + outputPath, + error: + 'Report shadow non creato', + }; + } + + let report; + + try { + report = JSON.parse( + fs.readFileSync( + outputPath, + 'utf8', + ), + ); + } catch (error) { + return { + executed: true, + ok: false, + backend: + config.backend, + output_path: + outputPath, + error: + 'Report shadow JSON non valido: ' + + String( + error.message || error, + ), + }; + } + + return { + executed: true, + + ok: + Number( + report.mismatch_count, + ) === 0, + + backend: + report.backend, + + device_name: + report.device_name, + + output_path: + outputPath, + + query_count: + Number( + report.query_count || 0, + ), + + prototype_count: + Number( + report.prototype_count || 0, + ), + + top1_matches: + Number( + report.top1_matches || 0, + ), + + decision_matches: + Number( + report.decision_matches || 0, + ), + + best_score_matches: + Number( + report.best_score_matches || 0, + ), + + top3_mean_matches: + Number( + report.top3_mean_matches || 0, + ), + + maximum_score_error: + Number( + report.maximum_score_error + || 0, + ), + + maximum_top3_mean_error: + Number( + report.maximum_top3_mean_error + || 0, + ), + + mismatch_count: + Number( + report.mismatch_count || 0, + ), + }; +} + +function getSelectedPrototypes(experiment, excludedFaceIds) { + const selected = experiment?.prototype_sets?.greedy_gain; + + if (!Array.isArray(selected) || selected.length === 0) { + throw new Error('Risultato prototype_sets.greedy_gain mancante'); + } + + return selected.filter( + (prototype) => !excludedFaceIds.has(prototype.face_id), + ); +} + +function compareSelections(current, selected) { + const currentIds = new Set(current.map((item) => item.face_id)); + const selectedIds = new Set(selected.map((item) => item.face_id)); + + const added = selected.filter((item) => !currentIds.has(item.face_id)); + const removed = current.filter((item) => !selectedIds.has(item.face_id)); + + return { + unchanged: added.length === 0 && removed.length === 0, + added, + removed, + }; +} + +async function claimJob(job, signature) { + const now = new Date().toISOString(); + + return db.transaction(async (trx) => { + const updated = await trx('face_prototype_jobs') + .where({ id: job.id, status: 'pending' }) + .update({ + status: 'processing', + started_at: now, + completed_at: null, + attempts: Number(job.attempts || 0) + 1, + last_error: null, + input_signature: signature, + updated_at: now, + }); + + return updated === 1; + }); +} + +function buildPrototypeRow(item, job, order, now, experimentName) { + return { + person_id: job.person_id, + face_id: item.face_id, + user: job.user, + source_type: String(item.source_type || 'manual'), + selection_method: String(item.selection_method || 'automatic'), + selection_order: order, + detector_score: Number(item.detector_score), + quality_score: null, + active: 1, + locked: 0, + experiment_name: experimentName, + metrics_json: JSON.stringify({ + photo_name: item.photo_name || null, + cluster_label: item.cluster_label ?? null, + metric_delta: item.metric_delta || null, + metrics_before: item.metrics_before || null, + metrics_after: item.metrics_after || null, + }), + created_by: job.requested_by || job.user, + created_at: now, + updated_at: now, + }; +} + +async function createReplacementJob(trx, job, now) { + const existing = await trx('face_prototype_jobs') + .where({ + user: job.user, + person_id: job.person_id, + status: 'pending', + }) + .first(); + + if (existing) { + await trx('face_prototype_jobs') + .where({ id: existing.id }) + .update({ + reason: 'input_changed_during_processing', + requested_by: job.requested_by || job.user, + requested_at: now, + updated_at: now, + }); + return existing.id; + } + + const replacementId = crypto.randomUUID(); + + await trx('face_prototype_jobs').insert({ + id: replacementId, + user: job.user, + person_id: job.person_id, + status: 'pending', + reason: 'input_changed_during_processing', + requested_by: job.requested_by || job.user, + requested_at: now, + started_at: null, + completed_at: null, + attempts: 0, + last_error: null, + result_json: null, + input_signature: null, + created_at: now, + updated_at: now, + }); + + return replacementId; +} + +async function persistResult(job, experiment, selected, comparison, signature) { + const now = new Date().toISOString(); + const experimentName = `prototype-worker-${job.id}`; + + return db.transaction(async (trx) => { + const currentJob = await trx('face_prototype_jobs') + .where({ id: job.id, status: 'processing' }) + .first(); + + if (!currentJob) { + throw new Error('Job non piu in stato processing'); + } + + const currentSignature = await computeInputSignature(trx, job); + + if (currentSignature !== signature) { + const replacementJobId = await createReplacementJob(trx, job, now); + + await trx('face_prototype_jobs') + .where({ id: job.id }) + .update({ + status: 'superseded', + completed_at: now, + result_json: JSON.stringify({ replacement_job_id: replacementJobId }), + updated_at: now, + }); + + return { + status: 'superseded', + replacement_job_id: replacementJobId, + }; + } + + const exclusions = await loadReferenceExclusions(trx, job); + const excludedIds = new Set(exclusions.map((item) => item.face_id)); + + const lockedRows = await trx('person_face_prototypes') + .where({ + person_id: job.person_id, + user: job.user, + active: 1, + locked: 1, + }) + .select('*'); + + const lockedIds = new Set( + lockedRows + .filter((item) => !excludedIds.has(item.face_id)) + .map((item) => item.face_id), + ); + + const automaticSelected = selected.filter( + (item) => !excludedIds.has(item.face_id) && !lockedIds.has(item.face_id), + ); + + await trx('person_face_prototypes') + .where({ + person_id: job.person_id, + user: job.user, + locked: 0, + }) + .update({ active: 0, updated_at: now }); + + let order = 1; + + for (const locked of lockedRows) { + if (excludedIds.has(locked.face_id)) { + await trx('person_face_prototypes') + .where({ person_id: job.person_id, face_id: locked.face_id }) + .update({ active: 0, updated_at: now }); + continue; + } + + await trx('person_face_prototypes') + .where({ person_id: job.person_id, face_id: locked.face_id }) + .update({ active: 1, selection_order: order, updated_at: now }); + order += 1; + } + + for (const item of automaticSelected) { + const row = buildPrototypeRow(item, job, order, now, experimentName); + + await trx('person_face_prototypes') + .insert(row) + .onConflict(['person_id', 'face_id']) + .merge({ + user: row.user, + source_type: row.source_type, + selection_method: row.selection_method, + selection_order: row.selection_order, + detector_score: row.detector_score, + quality_score: row.quality_score, + active: 1, + experiment_name: row.experiment_name, + metrics_json: row.metrics_json, + created_by: row.created_by, + updated_at: now, + }); + order += 1; + } + + const prototypeCount = order - 1; + const summary = + experiment + ?.evaluations + ?.greedy_gain + ?.summary + || null; + + const queries = + experiment + ?.evaluations + ?.greedy_gain + ?.queries; + + if ( + !Array.isArray(queries) + || queries.length === 0 + ) { + throw new Error( + 'Valutazioni greedy_gain ' + + 'mancanti o vuote', + ); + } + + const recognitionThreshold = + 0.50; + + /* + * Le valutazioni precedenti vengono + * sostituite atomicamente con quelle + * prodotte dal job corrente. + */ + await trx( + 'person_face_recognition_evaluations', + ) + .where({ + person_id: + job.person_id, + user: + job.user, + }) + .del(); + + let recognizedCount = 0; + let belowThresholdCount = 0; + + for (const query of queries) { + const rawBestSimilarity = + query.best_similarity; + + const rawTop3Mean = + query.top3_mean; + + const bestSimilarity = + rawBestSimilarity == null + ? null + : Number( + rawBestSimilarity, + ); + + const top3Mean = + rawTop3Mean == null + ? null + : Number( + rawTop3Mean, + ); + + const recognized = + bestSimilarity != null + && Number.isFinite( + bestSimilarity, + ) + && bestSimilarity + >= recognitionThreshold; + + if (recognized) { + recognizedCount += 1; + } else { + belowThresholdCount += 1; + } + + await trx( + 'person_face_recognition_evaluations', + ) + .insert({ + person_id: + job.person_id, + + face_id: + query.face_id, + + user: + job.user, + + prototype_job_id: + job.id, + + prototype_count: + prototypeCount, + + best_similarity: + bestSimilarity, + + top3_mean: + top3Mean, + + best_prototype_face_id: + query.best_prototype_face_id + || null, + + recognition_threshold: + recognitionThreshold, + + recognized: + recognized ? 1 : 0, + + evaluated_at: + now, + + created_at: + now, + + updated_at: + now, + }); + } + + const recognitionCoverage = { + total: + queries.length, + + recognized: + recognizedCount, + + below_threshold: + belowThresholdCount, + + threshold: + recognitionThreshold, + + percentage: + queries.length > 0 + ? Number( + ( + recognizedCount + / queries.length + * 100 + ).toFixed(1), + ) + : 0, + }; + + const result = { + selected_count: prototypeCount, + automatic_selected_count: + automaticSelected.length, + locked_count: + lockedIds.size, + evaluation_count: + queries.length, + recognition_coverage: + recognitionCoverage, + selected_face_ids: [ + ...lockedRows + .filter((item) => !excludedIds.has(item.face_id)) + .map((item) => item.face_id), + ...automaticSelected.map((item) => item.face_id), + ], + comparison: { + unchanged: comparison.unchanged, + added: comparison.added.map((item) => item.photo_name), + removed: comparison.removed.map((item) => item.photo_name), + }, + metrics: summary, + experiment_output: experiment.outputPath, + }; + + const state = await trx('face_recognition_index_state') + .where({ user: job.user }) + .first(); + + if (state) { + await trx('face_recognition_index_state') + .where({ user: job.user }) + .update({ + status: 'dirty', + dirty: 1, + prototype_count: prototypeCount, + last_error: null, + updated_at: now, + }); + } else { + await trx('face_recognition_index_state').insert({ + user: job.user, + status: 'dirty', + dirty: 1, + version: 0, + prototype_count: prototypeCount, + index_path: null, + metadata_path: null, + built_at: null, + last_error: null, + created_at: now, + updated_at: now, + }); + } + + await trx('face_prototype_jobs') + .where({ id: job.id }) + .update({ + status: 'done', + completed_at: now, + last_error: null, + result_json: JSON.stringify(result), + updated_at: now, + }); + + return { status: 'done', ...result }; + }); +} + +async function markJobError(job, error) { + const now = new Date().toISOString(); + const message = String(error?.stack || error?.message || error).slice(0, 20000); + + await db('face_prototype_jobs') + .where({ id: job.id, status: 'processing' }) + .update({ + status: 'error', + completed_at: now, + last_error: message, + updated_at: now, + }); +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + const job = await loadPendingJob(options); + + if (!job) { + console.log('[prototypeWorker] Nessun job pending.'); + return; + } + + const current = await loadCurrentPrototypes(db, job); + const exclusions = await loadReferenceExclusions(db, job); + const excludedFaceIds = new Set(exclusions.map((item) => item.face_id)); + const signature = await computeInputSignature(db, job); + + if (!options.persist) { + const experiment = runExperiment(job); + + experiment.data.outputPath = + experiment.outputPath; + const selected = getSelectedPrototypes(experiment.data, excludedFaceIds); + const comparison = compareSelections(current, selected); + + console.log({ + mode: 'dry-run', + job, + current_prototypes: current, + reference_exclusions: exclusions, + selected_prototypes: selected, + comparison, + metrics: experiment.data?.evaluations?.greedy_gain?.summary || null, + experiment_output: experiment.outputPath, + }); + + console.log( + '[prototypeWorker] dry-run completato. Job e prototype non modificati.', + ); + return; + } + + const claimed = await claimJob(job, signature); + + if (!claimed) { + throw new Error('Job gia acquisito da un altro worker'); + } + + try { + const experiment = runExperiment(job); + + experiment.data.outputPath = + experiment.outputPath; + const selected = getSelectedPrototypes(experiment.data, excludedFaceIds); + + if (selected.length === 0) { + throw new Error('Nessun prototype selezionato dopo le esclusioni'); + } + + const comparison = compareSelections(current, selected); + const result = await persistResult( + job, + experiment.data, + selected, + comparison, + signature, + ); + + /* + * persistResult() ha già concluso la + * transazione. Lo shadow OpenCL non + * modifica prototype, valutazioni o job. + */ + let faceKnnShadow; + + try { + faceKnnShadow = + runFaceKnnShadow( + job, + experiment.outputPath, + ); + + if ( + faceKnnShadow.executed + && faceKnnShadow.ok + ) { + console.log( + '[prototypeWorker] ' + + 'shadow OpenCL completato:', + faceKnnShadow, + ); + } else if ( + faceKnnShadow.executed + ) { + console.error( + '[prototypeWorker] ' + + 'shadow OpenCL non coerente:', + faceKnnShadow, + ); + } else { + console.log( + '[prototypeWorker] ' + + 'shadow OpenCL non eseguito:', + faceKnnShadow, + ); + } + } catch (shadowError) { + faceKnnShadow = { + executed: true, + ok: false, + error: String( + shadowError?.stack + || shadowError?.message + || shadowError, + ), + }; + + console.error( + '[prototypeWorker] ' + + 'shadow OpenCL fallito:', + faceKnnShadow, + ); + } + + console.log( + '[prototypeWorker] ' + + 'elaborazione completata:', + { + ...result, + face_knn_shadow: + faceKnnShadow, + }, + ); + } catch (error) { + await markJobError(job, error); + throw error; + } +} + +main() + .catch((error) => { + console.error( + '[prototypeWorker] errore:', + error.stack || error.message || error, + ); + process.exitCode = 1; + }) + .finally(async () => { + await db.destroy(); + }); diff --git a/tools/scanPhotoFaces.js b/tools/scanPhotoFaces.js new file mode 100644 index 0000000..4b28593 --- /dev/null +++ b/tools/scanPhotoFaces.js @@ -0,0 +1,39 @@ +const db = require('../db/knex'); + +const { + scanPhotoFacesById, +} = require('../api_v1/scanner/faceScanner'); + +async function main() { + const photoId = String( + process.argv[2] || '', + ).trim(); + + if (!photoId) { + console.error( + 'Uso: node tools/scanPhotoFaces.js PHOTO_ID', + ); + + process.exitCode = 2; + return; + } + + const result = await scanPhotoFacesById(photoId); + + console.log( + JSON.stringify(result, null, 2), + ); +} + +main() + .catch((error) => { + console.error( + '[scanPhotoFaces] errore:', + error.stack || error.message || error, + ); + + process.exitCode = 1; + }) + .finally(async () => { + await db.destroy(); + }); diff --git a/tools/taskTimer.js b/tools/taskTimer.js new file mode 100644 index 0000000..ce83bd1 --- /dev/null +++ b/tools/taskTimer.js @@ -0,0 +1,201 @@ +// tools/taskTimer.js + +require('dotenv').config(); + +const db = require('../db/knex'); + +const { + runCycle: runFaceClusteringCycle, +} = require( + './runFaceClusteringScheduler' +); + +const { + cleanupCast, +} = require( + './cleanupCast' +); + + +/* + * Face clustering: + * ogni ora. + */ +const CLUSTER_INTERVAL_MS = + 60 * 60 * 1000; + + +/* + * Cleanup Cast: + * una volta al giorno. + */ +const CLEANUP_INTERVAL_MS = + 24 * 60 * 60 * 1000; + + +let clusterRunning = false; +let cleanupRunning = false; + + +/* + * ============================================================ + * FACE CLUSTERING + * ============================================================ + */ +async function runClustering() { + if (clusterRunning) { + console.log( + '[taskTimer] clustering already running, skip' + ); + + return; + } + + clusterRunning = true; + + const startedAt = Date.now(); + + console.log( + '[taskTimer] clustering cycle start' + ); + + try { + await runFaceClusteringCycle(); + + console.log( + '[taskTimer] clustering cycle completed', + `elapsedMs=${Date.now() - startedAt}`, + ); + } catch (error) { + console.error( + '[taskTimer] clustering cycle error', + error, + ); + } finally { + clusterRunning = false; + } +} + + +/* + * ============================================================ + * CLEANUP CAST + * ============================================================ + */ +async function runCleanup() { + if (cleanupRunning) { + console.log('[taskTimer] cleanup already running, skip'); + return; + } + + cleanupRunning = true; + const startedAt = Date.now(); + + console.log('[taskTimer] cleanup cycle start'); + + try { + // Mantiene la pulizia HLS/Cast esistente. + try { + await cleanupCast({ dryRun: false }); + } catch (error) { + console.error('[taskTimer] cleanup Cast error', error); + } + + // Elimina solo job terminali completati da oltre 24 ore. + for (const table of [ + 'photo_ingest_jobs', + 'activity_ingest_jobs', + ]) { + try { + if (!(await db.schema.hasTable(table))) { + console.warn(`[taskTimer] table not found: ${table}`); + continue; + } + + const deleted = await db(table) + .whereIn('status', ['done', 'error']) + .whereNotNull('completed_at') + .whereRaw( + "datetime(completed_at) < datetime('now', '-24 hours')" + ) + .del(); + + console.log( + `[taskTimer] ${table}: removed ${deleted} expired jobs` + ); + } catch (error) { + console.error(`[taskTimer] ${table} cleanup error`, error); + } + } + + console.log( + '[taskTimer] cleanup cycle completed', + `elapsedMs=${Date.now() - startedAt}`, + ); + } finally { + cleanupRunning = false; + } +} + +/* + * ============================================================ + * MAIN + * ============================================================ + */ +async function main() { + console.log( + '[taskTimer] started' + ); + + console.log( + '[taskTimer] clustering interval:', + `${CLUSTER_INTERVAL_MS / 60000} minutes`, + ); + + console.log( + '[taskTimer] cleanup interval:', + `${CLEANUP_INTERVAL_MS / 3600000} hours`, + ); + + /* + * Clustering immediato all'avvio. + */ + await runClustering(); + + /* + * Cleanup anche all'avvio. + * + * ATTENZIONE: + * cleanupCast garantisce comunque che file + * con meno di 24 ore non vengano cancellati. + */ + await runCleanup(); + + setInterval( + () => { + void runClustering(); + }, + CLUSTER_INTERVAL_MS, + ); + + setInterval( + () => { + void runCleanup(); + }, + CLEANUP_INTERVAL_MS, + ); + + console.log( + '[taskTimer] timers armed' + ); +} + + +main().catch((error) => { + console.error( + '[taskTimer] fatal error', + error, + ); + + process.exitCode = 1; +}); diff --git a/tools/testClusterScheduler.js b/tools/testClusterScheduler.js new file mode 100644 index 0000000..32a3706 --- /dev/null +++ b/tools/testClusterScheduler.js @@ -0,0 +1,13 @@ +const { + runCycle, +} = require('./runFaceClusteringScheduler'); + +runCycle() + .then(() => { + console.log('OK'); + process.exit(0); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/utils/jwtHelpers.js b/utils/jwtHelpers.js new file mode 100644 index 0000000..27a4931 --- /dev/null +++ b/utils/jwtHelpers.js @@ -0,0 +1,41 @@ +const jwt = require('jsonwebtoken'); + +const SECRET_KEY = process.env.JWT_SECRET || '123456789'; +const EXPIRES_IN = process.env.JWT_EXPIRES || '1h'; + +const denylist = new Map(); + +function addToDenylist(token) { + try { + const decoded = jwt.decode(token); + const exp = decoded?.exp || Math.floor(Date.now() / 1000) + 60; + denylist.set(token, exp); + } catch { + denylist.set(token, Math.floor(Date.now() / 1000) + 60); + } +} + +function isRevoked(token) { + const exp = denylist.get(token); + if (!exp) return false; + if (exp * 1000 < Date.now()) { + denylist.delete(token); + return false; + } + return true; +} + +function verifyToken(token) { + return jwt.verify(token, SECRET_KEY); +} + +function createToken(payload) { + return jwt.sign(payload, SECRET_KEY, { expiresIn: EXPIRES_IN }); +} + +module.exports = { + addToDenylist, + isRevoked, + verifyToken, + createToken, +}; diff --git a/utils/range.js b/utils/range.js new file mode 100644 index 0000000..88d7974 --- /dev/null +++ b/utils/range.js @@ -0,0 +1,16 @@ +function parseRange(rangeHeader, totalLength) { + if (!rangeHeader) return null; + const m = /bytes=(\d*)-(\d*)/.exec(rangeHeader); + if (!m) return null; + + const start = m[1] ? parseInt(m[1], 10) : 0; + const end = m[2] ? parseInt(m[2], 10) : totalLength - 1; + + if (isNaN(start) || isNaN(end) || start > end || start >= totalLength) { + return null; + } + + return { start, end }; +} + +module.exports = { parseRange }; diff --git a/utils/relPath.js b/utils/relPath.js new file mode 100644 index 0000000..31905f3 --- /dev/null +++ b/utils/relPath.js @@ -0,0 +1,6 @@ +function relPath2Cartella(p) { + const parts = p.split('/').filter(Boolean); + return parts.slice(3).join('/'); +} + +module.exports = { relPath2Cartella }; diff --git a/utils/resolveFilePath.js b/utils/resolveFilePath.js new file mode 100644 index 0000000..3f01be6 --- /dev/null +++ b/utils/resolveFilePath.js @@ -0,0 +1,191 @@ +// utils/resolveFilePath.js +const fs = require('fs'); +const path = require('path'); +const db = require('../db/knex'); +const { WEB_ROOT } = require('../api_v1/config'); + +// In questo file __dirname = /utils. +// Quindi la root reale del progetto è un livello sopra. +const PROJECT_ROOT = path.resolve(__dirname, '..'); + +function existingPath(...parts) { + const p = path.resolve(...parts); + return fs.existsSync(p) ? p : null; +} + +function normalizeWebRoot() { + if (!WEB_ROOT || typeof WEB_ROOT !== 'string') return 'public'; + + // Se WEB_ROOT è assoluto, lo usiamo come root assoluta. + // Se è relativo, lo risolviamo dalla root progetto. + return WEB_ROOT; +} + +function resolveCandidatePath(candidate) { + if (!candidate || typeof candidate !== 'string') return null; + + const c = candidate.trim(); + if (!c) return null; + + const webRoot = normalizeWebRoot(); + + // =============================== + // PATH ASSOLUTO + // =============================== + if (path.isAbsolute(c)) { + const direct = existingPath(c); + if (direct) return direct; + + // Caso comune: + // DB salva "/photos/utente/original/..." + // ma il vero path è "//photos/..." + const underWebRoot = existingPath( + path.isAbsolute(webRoot) ? webRoot : path.join(PROJECT_ROOT, webRoot), + c.replace(/^\/+/, ''), + ); + if (underWebRoot) return underWebRoot; + + // Altro fallback: + // "/photos/..." + const underProject = existingPath(PROJECT_ROOT, c.replace(/^\/+/, '')); + if (underProject) return underProject; + + return null; + } + + // =============================== + // PATH RELATIVO + // =============================== + + // // + const p1 = existingPath( + path.isAbsolute(webRoot) ? webRoot : path.join(PROJECT_ROOT, webRoot), + c, + ); + if (p1) return p1; + + // / + const p2 = existingPath(PROJECT_ROOT, c); + if (p2) return p2; + + return null; +} + +async function getPhotoRowByAnyKnownId(entryId) { + const cleanId = String(entryId || '').trim(); + if (!cleanId) return null; + + // Prima prova id, che è il caso originale. + let row = await db('photos').where({ id: cleanId }).first(); + if (row) return row; + + // Poi prova colonne alternative, ma solo se esistono davvero. + // Così evitiamo errori tipo "no such column". + let columns = {}; + try { + columns = await db('photos').columnInfo(); + } catch (err) { + console.warn('[resolveFilePathForEntryId] columnInfo error:', err.message); + return null; + } + + const candidateColumns = [ + 'remoteId', + 'remote_id', + 'hash', + 'sha1', + 'checksum', + 'content_hash', + 'contentHash', + ]; + + for (const col of candidateColumns) { + if (!columns[col]) continue; + + row = await db('photos').where(col, cleanId).first(); + if (row) { + console.log(`[resolveFilePathForEntryId] found row by ${col}=${cleanId}`); + return row; + } + } + + console.warn('[resolveFilePathForEntryId] no row found for id/hash:', cleanId); + return null; +} + +async function resolveFilePathForEntryId(entryId) { + try { + const cleanId = String(entryId || '').trim(); + if (!cleanId) return null; + + const row = await getPhotoRowByAnyKnownId(cleanId); + if (!row) return null; + + const candidates = [ + row.path, + row.file, + row.file_path, + row.original, + row.uri, + row.fullpath, + row.storage_path, + row.local_path, + row.remotePath, + row.remote_path, + ]; + + for (const candidate of candidates) { + const resolved = resolveCandidatePath(candidate); + if (resolved) { + console.log('[resolveFilePathForEntryId] resolved from candidate:', { + entryId: cleanId, + candidate, + resolved, + }); + return resolved; + } + } + + // =============================== + // FALLBACK: user/cartella/name + // =============================== + if (row.user && row.cartella && (row.name || row.file)) { + const filename = row.name || row.file; + const webRoot = normalizeWebRoot(); + + const resolved = existingPath( + path.isAbsolute(webRoot) ? webRoot : path.join(PROJECT_ROOT, webRoot), + 'photos', + row.user, + 'original', + row.cartella, + filename, + ); + + if (resolved) { + console.log('[resolveFilePathForEntryId] resolved from user/cartella/name:', { + entryId: cleanId, + resolved, + }); + return resolved; + } + } + + console.warn('[resolveFilePathForEntryId] file path not resolved:', { + entryId: cleanId, + rowId: row.id, + user: row.user, + cartella: row.cartella, + name: row.name, + path: row.path, + file: row.file, + }); + + return null; + } catch (err) { + console.error('resolveFilePathForEntryId error:', err); + return null; + } +} + +module.exports = { resolveFilePathForEntryId }; diff --git a/utils/shareTokens.js b/utils/shareTokens.js new file mode 100644 index 0000000..10e4d8f --- /dev/null +++ b/utils/shareTokens.js @@ -0,0 +1,59 @@ +const { v4: uuidv4 } = require('uuid'); + +const shareTokens = new Map(); +const SHARE_CLEANUP_INTERVAL_MS = 60 * 1000; + +function createShareRecord( + entryId, + filePath, + ttlSeconds = 300, + oneTime = false, + owner = null, + options = {}, +) { + const token = uuidv4(); + const expiresAt = Date.now() + ttlSeconds * 1000; + + const record = { + entryId, + filePath, + expiresAt, + oneTime, + owner, + variant: options.variant || "original", + originalPath: options.originalPath || filePath, + castPath: options.castPath || null, + }; + + shareTokens.set(token, record); + + return { + token, + expiresAt, + }; +} + +function getShareRecord(token) { + const rec = shareTokens.get(token); + if (!rec) return null; + if (rec.expiresAt <= Date.now()) { + shareTokens.delete(token); + return null; + } + return rec; +} + +function cleanupShareTokens() { + const now = Date.now(); + for (const [t, rec] of shareTokens.entries()) { + if (rec.expiresAt <= now) shareTokens.delete(t); + } +} + +setInterval(cleanupShareTokens, SHARE_CLEANUP_INTERVAL_MS); + +module.exports = { + createShareRecord, + getShareRecord, + shareTokens, +}; diff --git a/watcher/.env b/watcher/.env new file mode 100644 index 0000000..58fc58a --- /dev/null +++ b/watcher/.env @@ -0,0 +1,2 @@ +BASE_URL=https://prova.patachina.it +WATCHER_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiV2F0Y2hlciIsImlhdCI6MTc3NDcwNTExNiwiZXhwIjoxODA2MjQxMTE2fQ.uyX-fUNnZKehSMLoy10l99juwsMTCWevchKHSMcsoIo diff --git a/watcher/README.md b/watcher/README.md new file mode 100644 index 0000000..8c5cb12 --- /dev/null +++ b/watcher/README.md @@ -0,0 +1,11 @@ +# Watcher per galleria + +fare il setup su docker-compose.yml + +lanciare con + +``` +sudo docker compose up --build +``` + +è settata per la porta 4002 e in nginx ws-prova.patachina.it diff --git a/watcher/start.sh b/watcher/start.sh new file mode 100755 index 0000000..8554cfa --- /dev/null +++ b/watcher/start.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +echo "== Avvio watcher globale ==" + +inotifywait -m -r \ + -e close_write,delete,move \ + --format '%w %e %f' \ + ../public/photos \ + ../public/activities \ +| node watcher_logic.mjs diff --git a/watcher/watcher_logic.mjs b/watcher/watcher_logic.mjs new file mode 100644 index 0000000..f0ab49f --- /dev/null +++ b/watcher/watcher_logic.mjs @@ -0,0 +1,413 @@ +import readline from "readline"; +import dotenv from "dotenv"; + +dotenv.config(); + +// =============================== +// CONFIG +// =============================== + +const BASE_URL = process.env.BASE_URL; + +if (!BASE_URL) { + console.error("ERRORE: BASE_URL non definita nel .env"); + process.exit(1); +} + +const WATCHER_TOKEN = process.env.WATCHER_TOKEN; + +if (!WATCHER_TOKEN) { + console.error("ERRORE: WATCHER_TOKEN non definito nel .env"); + process.exit(1); +} + +console.log("Watcher globale avviato"); + +// =============================== +// STDIN +// =============================== + +const rl = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity +}); + +// =============================== +// ESTENSIONI FOTO / VIDEO +// =============================== + +const photoExt = [ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".bmp", + ".webp", + ".tiff", + ".heic", + ".heif", + ".mp4", + ".mov", + ".avi", + ".mkv", + ".webm", + ".m4v" +]; + +// =============================== +// ESTENSIONI ATTIVITÀ +// =============================== + +const activityExt = [ + ".fit", + ".gpx", + ".tcx" +]; + +// =============================== +// PARSING PATH +// =============================== + +function parsePath(fullPath) { + + const normalized = fullPath.replace(/\\/g, "/"); + + /* + * Supporta sia: + * + * /app/photos/Fabio/original/... + * /app/activities/Fabio/original/... + * + * sia: + * + * ../public/photos/Fabio/original/... + * ../public/activities/Fabio/original/... + * + */ + + const match = normalized.match( + /(?:^|\/)(photos|activities)\/([^/]+)\/original(?:\/|$)/ + ); + + if (!match) { + return null; + } + + return { + area: match[1], + user: match[2] + }; +} + +// =============================== +// NORMALIZZAZIONE PATH +// =============================== + +function normalizePath(fullPath) { + + const normalized = fullPath.replace(/\\/g, "/"); + + /* + * Trasforma: + * + * ../public/photos/Fabio/original/traptour/ + * + * in: + * + * /photos/Fabio/original/traptour/ + * + * + * e anche: + * + * /app/photos/Fabio/original/traptour/ + * + * in: + * + * /photos/Fabio/original/traptour/ + */ + + const match = normalized.match( + /(?:^|\/)(photos|activities)\/(.+)$/ + ); + + if (!match) { + return null; + } + + return `/${match[1]}/${match[2]}`; +} + +// =============================== +// FILTRO FILE +// =============================== + +function isAllowedFile(area, filename) { + + const lower = filename.toLowerCase(); + + if (area === "photos") { + return photoExt.some(ext => lower.endsWith(ext)); + } + + if (area === "activities") { + return activityExt.some(ext => lower.endsWith(ext)); + } + + return false; +} + +// =============================== +// CHIAMATA API +// =============================== + +async function callAutoScan(type, file, path, user) { + + const relPath = normalizePath(path); + + if (!relPath) { + console.error( + "Impossibile normalizzare path:", + path + ); + + return false; + } + + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${WATCHER_TOKEN}` + }; + + const payload = { + type, + file, + path: relPath, + user + }; + + console.log("AUTO_SCAN:", payload); + + try { + + const res = await fetch( + `${BASE_URL}/api_v1/auto_scan`, + { + method: "POST", + headers, + body: JSON.stringify(payload) + } + ); + + if (!res.ok) { + + console.error( + `Errore auto_scan ${res.status}` + ); + + try { + const text = await res.text(); + + if (text) { + console.error( + "Risposta API:", + text + ); + } + } + catch (_) { + // niente + } + + return false; + } + + console.log( + `AUTO_SCAN OK ${res.status}` + ); + + return true; + + } + catch (err) { + + console.error( + "Errore chiamata auto_scan:", + err + ); + + return false; + } +} + +// =============================== +// WATCHER +// =============================== + +function startWatcher() { + + rl.on("line", async line => { + + console.log("RAW:", line); + + try { + + const parts = line.trim().split(/\s+/); + + if (parts.length < 3) { + + console.log( + "EVENTO SCARTATO: formato non valido" + ); + + return; + } + + const path = parts[0]; + const action = parts[1]; + const file = parts.slice(2).join(" "); + + console.log({ + path, + action, + file + }); + + // =============================== + // PARSE PATH + // =============================== + + const info = parsePath(path); + + console.log( + "PARSE_PATH:", + info + ); + + if (!info) { + + console.log( + "PATH IGNORATO" + ); + + return; + } + + const { + user, + area + } = info; + + // =============================== + // DIRECTORY? + // =============================== + + const isDir = action.includes("ISDIR"); + + // =============================== + // FILTRO ESTENSIONE + // =============================== + + if ( + !isDir && + !isAllowedFile(area, file) + ) { + + console.log( + `FILE IGNORATO area=${area} file=${file}` + ); + + return; + } + + // =============================== + // TIPO EVENTO + // =============================== + + let type = null; + + if ( + action === "MOVED_TO,ISDIR" + ) { + + type = "ADD_DIR"; + } + + else if ( + action === "MOVED_FROM,ISDIR" + ) { + + type = "DEL_DIR"; + } + + else if ( + /^CLOSE_WRITE(,CLOSE)?$/.test(action) + ) { + + type = "ADD"; + } + + else if ( + action === "MOVED_TO" + ) { + + type = "ADD"; + } + + else if ( + action === "MOVED_FROM" + ) { + + type = "DEL"; + } + + else if ( + action === "DELETE" + ) { + + type = "DEL"; + } + + else { + + console.log( + `AZIONE IGNORATA: ${action}` + ); + + return; + } + + // =============================== + // EVENTO + // =============================== + + console.log( + `[${area}] ${user} -> ${type} ${file}` + ); + + // =============================== + // API + // =============================== + + await callAutoScan( + type, + file, + path, + user + ); + + } + catch (err) { + + console.error( + "Errore watcher:", + err + ); + } + }); +} + +// =============================== +// START +// =============================== + +startWatcher(); diff --git a/ws-server.js b/ws-server.js new file mode 100644 index 0000000..6236251 --- /dev/null +++ b/ws-server.js @@ -0,0 +1,24 @@ +// ws-server.js +const { wss } = require("./ws-server/ws-core"); +const { broadcastPhotoEvent } = require("./ws-server/ws-events-photos"); +const { broadcastActivityEvent } = require("./ws-server/ws-events-activities"); + +const createWsCompat = require("./ws-server/ws-compat"); + +const { + broadcastToUser, + broadcastToAdmins +} = createWsCompat({ + broadcastPhotoEvent, + broadcastActivityEvent +}); + +module.exports = { + wss, + broadcastPhotoEvent, + broadcastActivityEvent, + + // Compatibilità vecchio codice + broadcastToUser, + broadcastToAdmins +}; diff --git a/ws-server/ws-compat.js b/ws-server/ws-compat.js new file mode 100644 index 0000000..2a45ce0 --- /dev/null +++ b/ws-server/ws-compat.js @@ -0,0 +1,51 @@ +// ws-server/ws-compat.js + +function createWsCompat({ + broadcastPhotoEvent, + broadcastActivityEvent +}) { + async function broadcastToUser(user, payload) { + try { + if (!user || !payload) { + console.warn("⚠️ broadcastToUser chiamato senza user/payload"); + return; + } + + const entity = payload.entity || payload.typeEntity; + + // Eventi attività + if (entity === "activity" || entity === "activities") { + if (typeof broadcastActivityEvent === "function") { + return await broadcastActivityEvent(user, payload); + } + + console.warn("⚠️ broadcastActivityEvent non disponibile"); + return; + } + + // Default: eventi foto + if (typeof broadcastPhotoEvent === "function") { + return await broadcastPhotoEvent(user, payload); + } + + console.warn("⚠️ broadcastPhotoEvent non disponibile"); + } catch (err) { + console.warn("⚠️ broadcastToUser fallito:", err.message); + } + } + + async function broadcastToAdmins(payload) { + try { + return await broadcastToUser("Admin", payload); + } catch (err) { + console.warn("⚠️ broadcastToAdmins fallito:", err.message); + } + } + + return { + broadcastToUser, + broadcastToAdmins + }; +} + +module.exports = createWsCompat; diff --git a/ws-server/ws-core.js b/ws-server/ws-core.js new file mode 100644 index 0000000..18318dd --- /dev/null +++ b/ws-server/ws-core.js @@ -0,0 +1,254 @@ +// ws-server/ws-core.js +require("dotenv").config(); + +const WebSocket = require("ws"); +const jwt = require("jsonwebtoken"); +const { randomUUID } = require("crypto"); +const dbWs = require("../db/dbWs"); + +const PORT = process.env.WS_PORT || 4002; +const HOST = process.env.WS_HOST || "0.0.0.0"; +const JWT_SECRET = process.env.JWT_SECRET; + +if (!JWT_SECRET) { + console.error("❌ ERRORE: JWT_SECRET non definito nel .env"); + process.exit(1); +} + +const wss = new WebSocket.Server({ port: PORT, host: HOST }); + +console.log(`🚀 WebSocket server attivo su ws://${HOST}:${PORT}`); + +(async () => { + try { + console.log( + "🧹 [WS CLEANUP] Pulizia sessioni e pending al riavvio server...", + ); + await dbWs.deleteAllSessions(); + await dbWs.deleteAllPendingEvents(); + console.log("🧹 [WS CLEANUP] Completata"); + } catch (err) { + console.error("❌ [WS CLEANUP ERROR]:", err); + } +})(); + +const wsBySession = new Map(); +const WS_DORMANT_MS = 2 * 60 * 1000; + +function sendToClient(ws, msg) { + const json = JSON.stringify(msg); + console.log(`📤 [WS SEND] → user=${ws.user}: ${json}`); + ws.send(json); +} + +async function decideAuthRecovery({ session_id, user, device_id }) { + const sessionRow = await dbWs.getSession(session_id, device_id); + + let needFull = false; + let needRecovery = false; + let reason = "none"; + + if (!sessionRow) { + needRecovery = true; + reason = "session not found after cleanup"; + } else { + const lastAck = sessionRow.last_ack || 0; + const dormancy = lastAck > 0 ? Date.now() - lastAck : 0; + + if (sessionRow.need_full_sync) { + needRecovery = true; + reason = "db recovery flag"; + } else if (lastAck > 0 && dormancy >= WS_DORMANT_MS) { + needRecovery = true; + reason = "dormant"; + } else { + needRecovery = false; + reason = "recent or clean auth"; + } + } + + await dbWs.setSessionNeedFullSync(session_id, device_id, needRecovery); + + console.log("🧪 [WS AUTH DECISION]", { + user, + session_id, + device_id, + needFull, + needRecovery, + reason, + last_ack: sessionRow?.last_ack, + last_sync: sessionRow?.last_sync, + db_need_full_sync: sessionRow?.need_full_sync, + }); + + return { needFull, needRecovery, reason }; +} + +wss.on("connection", (ws) => { + console.log("🔌 [WS CONNECT] Nuovo client"); + + ws.authenticated = false; + ws.user = null; + ws.session_id = null; + ws.device_id = null; + ws.request_id = null; + + ws.on("message", async (msg) => { + console.log(`📩 [WS RAW] msg=${msg.toString()}`); + + let data; + try { + data = JSON.parse(msg.toString()); + } catch (err) { + console.error("❌ Errore parsing JSON:", err); + return; + } + + if (data.type === "auth") { + let payload; + + try { + payload = jwt.verify(data.token, JWT_SECRET); + const user = payload.name; + const session_id = data.session_id || randomUUID(); + const device_id = data.device_id || "default-device"; + const request_id = data.request_id || null; + + ws.user = user; + ws.session_id = session_id; + ws.device_id = device_id; + ws.request_id = request_id; + ws.authenticated = true; + + if (!wsBySession.has(session_id)) { + wsBySession.set(session_id, new Map()); + } + wsBySession.get(session_id).set(device_id, ws); + + console.log( + `🔐 [WS AUTH OK] user=${user} session=${session_id} device=${device_id}`, + ); + + await dbWs.upsertSession(session_id, user, device_id); + + const decision = await decideAuthRecovery({ + session_id, + user, + device_id, + }); + + sendToClient(ws, { + type: "auth_ok", + user, + session_id, + need_full_sync: decision.needFull, + need_recovery: decision.needRecovery, + reason: decision.reason, + }); + } catch (err) { + if (err.name === "TokenExpiredError") { + sendToClient(ws, { + type: "token_expired", + }); + + return; + } + + console.error("❌ [WS AUTH ERROR]:", err); + + sendToClient(ws, { + type: "auth_error", + error: err.message, + }); + + ws.close(); + } + return; + } + + if (!ws.authenticated) { + sendToClient(ws, { type: "error", message: "Not authenticated" }); + return; + } + + if (data.type === "ack" && data.event_id) { + console.log(`🟢 [WS ACK] user=${ws.user} event=${data.event_id}`); + await dbWs.updateSessionAck(ws.session_id, ws.device_id); + await dbWs.deletePendingEvent({ + user_id: ws.user, + session_id: ws.session_id, + device_id: ws.device_id, + event_id: data.event_id, + }); + return; + } + + if (data.type === "client_recovered") { + console.log(`🔄 [WS CLIENT RECOVERED] user=${ws.user}`); + + const session = await dbWs.getSession(ws.session_id, ws.device_id); + + if (!session) { + sendToClient(ws, { type: "need_recovery", reason: "session missing" }); + return; + } + + const lastAck = session.last_ack || 0; + const dormancy = lastAck > 0 ? Date.now() - lastAck : 0; + + if (dormancy >= WS_DORMANT_MS || session.need_full_sync) { + sendToClient(ws, { + type: "need_recovery", + reason: dormancy >= WS_DORMANT_MS ? "dormant" : "db recovery flag", + }); + return; + } + + const pending = await dbWs.getPendingEvents( + ws.user, + ws.session_id, + ws.device_id, + ); + + for (const ev of pending) { + ws.send(ev.payload); + } + + return; + } + + if (data.type === "pong") { + await dbWs.updateSessionAck(ws.session_id, ws.device_id); + return; + } + + if (data.type === "recovery_done") { + console.log( + `✅ [WS RECOVERY DONE] user=${ws.user} session=${ws.session_id} device=${ws.device_id}`, + ); + await dbWs.clearNeedFullSync(ws.session_id, ws.device_id); + await dbWs.updateSessionAck(ws.session_id, ws.device_id); + return; + } + + console.log(`📨 [WS MESSAGE] user=${ws.user} data=${JSON.stringify(data)}`); + }); + + ws.on("close", () => { + console.log(`❌ [WS DISCONNECT] user=${ws.user} session=${ws.session_id}`); + + if (ws.session_id && ws.device_id) { + const devices = wsBySession.get(ws.session_id); + + if (devices) { + devices.delete(ws.device_id); + + if (devices.size === 0) { + wsBySession.delete(ws.session_id); + } + } + } + }); +}); + +module.exports = { wss, wsBySession }; diff --git a/ws-server/ws-events-activities.js b/ws-server/ws-events-activities.js new file mode 100644 index 0000000..5322297 --- /dev/null +++ b/ws-server/ws-events-activities.js @@ -0,0 +1,40 @@ +// ws-server/ws-events-activities.js +const { randomUUID } = require("crypto"); +const dbWs = require("../db/dbWs"); +const { wss, wsBySession } = require("./ws-core"); + +async function broadcastActivityEvent(user, payload) { + const event_id = randomUUID(); + const now = Date.now(); + + const sessions = await dbWs.getSessionsByUser(user); + if (!sessions || sessions.length === 0) return; + + for (const s of sessions) { + const deviceRows = await dbWs.getDevicesForSession(s.session_id); + + for (const device_id of deviceRows) { + await dbWs.insertPendingEvent({ + user_id: user, + session_id: s.session_id, + device_id, + event_id, + payload: JSON.stringify(payload), + created_at: now, + }); + } + } + + for (const s of sessions) { + const devices = wsBySession.get(s.session_id); + if (!devices) continue; + + for (const [device_id, ws] of devices) { + if (ws.readyState === 1 && ws.authenticated) { + ws.send(JSON.stringify({ ...payload, event_id })); + } + } + } +} + +module.exports = { broadcastActivityEvent }; diff --git a/ws-server/ws-events-photos.js b/ws-server/ws-events-photos.js new file mode 100644 index 0000000..0030d52 --- /dev/null +++ b/ws-server/ws-events-photos.js @@ -0,0 +1,40 @@ +// ws-server/ws-events-photos.js +const { randomUUID } = require("crypto"); +const dbWs = require("../db/dbWs"); +const { wss, wsBySession } = require("./ws-core"); + +async function broadcastPhotoEvent(user, payload) { + const event_id = randomUUID(); + const now = Date.now(); + + const sessions = await dbWs.getSessionsByUser(user); + if (!sessions || sessions.length === 0) return; + + for (const s of sessions) { + const deviceRows = await dbWs.getDevicesForSession(s.session_id); + + for (const device_id of deviceRows) { + await dbWs.insertPendingEvent({ + user_id: user, + session_id: s.session_id, + device_id, + event_id, + payload: JSON.stringify(payload), + created_at: now, + }); + } + } + + for (const s of sessions) { + const devices = wsBySession.get(s.session_id); + if (!devices) continue; + + for (const [device_id, ws] of devices) { + if (ws.readyState === 1 && ws.authenticated) { + ws.send(JSON.stringify({ ...payload, event_id })); + } + } + } +} + +module.exports = { broadcastPhotoEvent }; diff --git a/ws-server/ws-outbox-consumer.js b/ws-server/ws-outbox-consumer.js new file mode 100644 index 0000000..e248482 --- /dev/null +++ b/ws-server/ws-outbox-consumer.js @@ -0,0 +1,119 @@ +'use strict'; + +const db = require('../db/knex'); +const { + broadcastPhotoEvent, + broadcastActivityEvent, +} = require('../ws-server'); + +const { + ensureServerEventOutbox, + getNextPendingServerEvent, + completeServerEvent, + failServerEvent, +} = require('../db/serverEventOutbox'); + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function dispatchEvent(event) { + let payload; + + try { + payload = JSON.parse(event.payload); + } catch (err) { + throw new Error( + `Payload JSON non valido per evento ${event.id}: ${err.message}` + ); + } + + switch (event.channel) { + case 'photo': + await broadcastPhotoEvent(event.user, payload); + return; + + case 'activity': + await broadcastActivityEvent(event.user, payload); + return; + + default: + throw new Error( + `Canale outbox sconosciuto: ${event.channel}` + ); + } +} + +function startWsOutboxConsumer(options = {}) { + const pollMs = Math.max( + 100, + Number(options.pollMs) || 250 + ); + + let stopping = false; + + async function loop() { + await ensureServerEventOutbox(db); + + console.log( + `[WsOutboxConsumer] avviato; polling ${pollMs} ms` + ); + + while (!stopping) { + try { + const event = await getNextPendingServerEvent(db); + + if (!event) { + await sleep(pollMs); + continue; + } + + try { + await dispatchEvent(event); + await completeServerEvent(db, event.id); + + console.log( + `[WsOutboxConsumer] evento ${event.id} inviato ` + + `channel=${event.channel} user=${event.user}` + ); + } catch (err) { + await failServerEvent( + db, + event.id, + err.message + ); + + console.error( + `[WsOutboxConsumer] evento ${event.id} fallito:`, + err.message + ); + + // Evita un busy-loop continuo su un evento non valido. + await sleep(pollMs); + } + } catch (err) { + console.error( + '[WsOutboxConsumer] errore ciclo:', + err + ); + + await sleep(pollMs); + } + } + } + + loop().catch((err) => { + console.error( + '[WsOutboxConsumer] errore fatale:', + err + ); + }); + + return () => { + stopping = true; + }; +} + +module.exports = { + startWsOutboxConsumer, +};