From d321b4f384af0d12f8eac77ae520e247dcfb94d0 Mon Sep 17 00:00:00 2001 From: sebastjanartic <45803536-sebastjanartic@users.noreply.replit.com> Date: Fri, 29 Aug 2025 07:34:08 +0000 Subject: [PATCH] Add face detection and thumbnail centering for videos This commit introduces face detection capabilities to the video platform, enabling automatic identification of faces in video thumbnails. It integrates face-api.js and sharp for image analysis, allowing for face-centered thumbnail crops and dynamic object-positioning. New API endpoints are added to process thumbnails individually and in batches. The database schema is updated to store face detection data, and the storage layer is modified to support these updates and cache face data. The project's dependencies are also updated to include necessary libraries for these new features. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 2eb1084e-b728-4449-9231-f1665924c8d5 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/2eb1084e-b728-4449-9231-f1665924c8d5/xF0EUqR --- .replit | 3 +- client/src/components/video-card.tsx | 4 + package-lock.json | 956 ++++++++++++++++++++++++++- package.json | 5 + pyproject.toml | 10 + server/face-detection-js.ts | 258 ++++++++ server/face-detection-service.ts | 145 ++++ server/face-detection.py | 234 +++++++ server/routes.ts | 101 +++ server/simple-face-detection.ts | 322 +++++++++ server/smart-thumbnail-service.ts | 316 +++++++++ server/storage.ts | 54 +- shared/schema.ts | 3 + uv.lock | 917 +++++++++++++++++++++++++ 14 files changed, 3319 insertions(+), 9 deletions(-) create mode 100644 pyproject.toml create mode 100644 server/face-detection-js.ts create mode 100644 server/face-detection-service.ts create mode 100644 server/face-detection.py create mode 100644 server/simple-face-detection.ts create mode 100644 server/smart-thumbnail-service.ts create mode 100644 uv.lock diff --git a/.replit b/.replit index c7ba47e..1c624ba 100644 --- a/.replit +++ b/.replit @@ -1,9 +1,10 @@ -modules = ["nodejs-20", "web", "postgresql-16"] +modules = ["nodejs-20", "web", "postgresql-16", "python-3.11"] run = "npm run dev" hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"] [nix] channel = "stable-24_05" +packages = ["freetype", "lcms2", "libGL", "libGLU", "libimagequant", "libjpeg", "libtiff", "libwebp", "libxcrypt", "opencv", "openjpeg", "python3", "tcl", "tk", "zlib"] [deployment] deploymentTarget = "autoscale" diff --git a/client/src/components/video-card.tsx b/client/src/components/video-card.tsx index d324496..5c6eb86 100644 --- a/client/src/components/video-card.tsx +++ b/client/src/components/video-card.tsx @@ -56,6 +56,10 @@ export default function VideoCard({ video, onClick, className = "" }: VideoCardP src={video.thumbnailUrl} alt={video.title} className="w-full h-full object-cover transition-all duration-300 group-hover:scale-105" + style={{ + objectPosition: video.faceCenterPosition || 'center center', + objectFit: 'cover' + }} data-testid={`img-thumbnail-${video.id}`} loading="lazy" decoding="async" diff --git a/package-lock.json b/package-lock.json index 6644db4..2182c25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "@types/bcryptjs": "^2.4.6", "@types/memoizee": "^0.4.12", "@types/multer": "^2.0.0", + "@types/node-fetch": "^2.6.13", "@types/video.js": "^7.3.58", "@uppy/aws-s3": "^4.3.2", "@uppy/core": "^4.5.2", @@ -54,6 +55,7 @@ "@uppy/progress-bar": "^4.3.2", "@uppy/react": "^4.5.2", "bcryptjs": "^3.0.2", + "canvas": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -65,6 +67,7 @@ "embla-carousel-react": "^8.6.0", "express": "^4.21.2", "express-session": "^1.18.2", + "face-api.js": "^0.22.2", "framer-motion": "^11.13.1", "google-auth-library": "^10.2.1", "hls.js": "^1.6.7", @@ -74,6 +77,7 @@ "memorystore": "^1.6.7", "multer": "^2.0.2", "next-themes": "^0.4.6", + "node-fetch": "^3.3.2", "openid-client": "^6.6.3", "passport": "^0.7.0", "passport-local": "^1.0.0", @@ -85,6 +89,7 @@ "react-resizable-panels": "^2.1.7", "react-share": "^5.2.2", "recharts": "^2.15.2", + "sharp": "^0.34.3", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", @@ -436,6 +441,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild-kit/core-utils": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", @@ -1648,6 +1663,424 @@ "react-hook-form": "^7.0.0" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", + "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", + "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.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", + "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "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.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", + "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.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", + "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "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.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", + "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", + "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.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", + "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", + "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.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", + "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", + "cpu": [ + "ppc64" + ], + "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.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", + "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", + "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.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", + "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", + "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.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", + "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "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.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", + "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "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.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", + "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", + "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.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", + "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", + "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.0" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", + "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "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.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", + "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", + "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.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", + "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", + "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.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", + "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "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.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", + "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "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.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", + "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.4" + }, + "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.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", + "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "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.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", + "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", + "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.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", + "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", + "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/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3785,6 +4218,32 @@ "react": "^18 || ^19" } }, + "node_modules/@tensorflow/tfjs-core": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-1.7.0.tgz", + "integrity": "sha512-uwQdiklNjqBnHPeseOdG0sGxrI3+d6lybaKu2+ou3ajVeKdPEwpWbgqA6iHjq1iylnOGkgkbbnQ6r2lwkiIIHw==", + "license": "Apache-2.0", + "dependencies": { + "@types/offscreencanvas": "~2019.3.0", + "@types/seedrandom": "2.4.27", + "@types/webgl-ext": "0.0.30", + "@types/webgl2": "0.0.4", + "node-fetch": "~2.1.2", + "seedrandom": "2.4.3" + }, + "engines": { + "yarn": ">= 1.3.2" + } + }, + "node_modules/@tensorflow/tfjs-core/node_modules/node-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.1.2.tgz", + "integrity": "sha512-IHLHYskTc2arMYsHZH82PVX8CSKT5lzb7AXeyO06QnjGDKtkv+pv3mEki6S7reB/x1QPo+YPxQRNEVgR5V/w3Q==", + "license": "MIT", + "engines": { + "node": "4.x || >=6.0.0" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -4028,6 +4487,38 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/node-fetch/node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "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/@types/offscreencanvas": { + "version": "2019.3.0", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz", + "integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==", + "license": "MIT" + }, "node_modules/@types/passport": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", @@ -4130,6 +4621,12 @@ "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", "license": "MIT" }, + "node_modules/@types/seedrandom": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.27.tgz", + "integrity": "sha512-YvMLqFak/7rt//lPBtEHv3M4sRNA+HGxrhFZ+DQs9K2IkYJbNwVIb8avtJfhDiuaUBX/AW0jnjv48FV8h3u9bQ==", + "license": "MIT" + }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", @@ -4163,6 +4660,18 @@ "integrity": "sha512-1CQjuSrgbv1/dhmcfQ83eVyYbvGyqhTvb2Opxr0QCV+iJ4J6/J+XWQ3Om59WiwCd1MN3rDUHasx5XRrpUtewYQ==", "license": "MIT" }, + "node_modules/@types/webgl-ext": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz", + "integrity": "sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg==", + "license": "MIT" + }, + "node_modules/@types/webgl2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@types/webgl2/-/webgl2-0.0.4.tgz", + "integrity": "sha512-PACt1xdErJbMUOUweSrbVM7gSIYm1vTncW2hF6Os/EeWi6TXYAYMPp+8v6rzHmypE5gHrxaxZNXgMkJVIdZpHw==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.5.13", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", @@ -5011,6 +5520,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -5104,6 +5624,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "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", @@ -5218,6 +5762,20 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.0.tgz", + "integrity": "sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -5254,6 +5812,12 @@ "node": ">= 6" } }, + "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/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5381,6 +5945,19 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5399,6 +5976,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -5757,6 +6344,30 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "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/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -5812,10 +6423,10 @@ } }, "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", - "dev": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", "engines": { "node": ">=8" } @@ -6769,6 +7380,15 @@ "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", "license": "MIT" }, + "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": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -6894,6 +7514,22 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/face-api.js": { + "version": "0.22.2", + "resolved": "https://registry.npmjs.org/face-api.js/-/face-api.js-0.22.2.tgz", + "integrity": "sha512-9Bbv/yaBRTKCXjiDqzryeKhYxmgSjJ7ukvOvEBy6krA0Ah/vNBlsf7iBNfJljWiPA8Tys1/MnB3lyP2Hfmsuyw==", + "license": "MIT", + "dependencies": { + "@tensorflow/tfjs-core": "1.7.0", + "tslib": "^1.11.1" + } + }, + "node_modules/face-api.js/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/fast-equals": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", @@ -7129,6 +7765,12 @@ "node": ">= 0.6" } }, + "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/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -7257,6 +7899,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "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/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -7529,12 +8177,38 @@ "node": ">=0.10.0" } }, + "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/input-otp": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", @@ -7562,6 +8236,12 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -8285,6 +8965,18 @@ "node": ">= 0.6" } }, + "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/min-document": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", @@ -8344,6 +9036,12 @@ "mkdirp": "bin/cmd.js" } }, + "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/modern-screenshot": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.6.0.tgz", @@ -8450,6 +9148,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "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": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -8474,6 +9178,36 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "license": "ISC" }, + "node_modules/node-abi": { + "version": "3.75.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", + "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -9205,6 +9939,32 @@ "url": "https://opencollective.com/preact" } }, + "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==", + "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/pretty-bytes": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", @@ -9296,6 +10056,16 @@ "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", "license": "ISC" }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -9364,6 +10134,21 @@ "node": ">= 0.8" } }, + "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/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -9804,6 +10589,12 @@ "loose-envify": "^1.1.0" } }, + "node_modules/seedrandom": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-2.4.3.tgz", + "integrity": "sha512-2CkZ9Wn2dS4mMUWQaXLsOAfGD+irMlLEeSP3cMxpGbgyOOzJGFa+MWCOMTOCMyZinHRPxyOj/S/C57li/1to6Q==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -9906,6 +10697,60 @@ "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==", "license": "MIT" }, + "node_modules/sharp": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", + "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.4", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.3", + "@img/sharp-darwin-x64": "0.34.3", + "@img/sharp-libvips-darwin-arm64": "1.2.0", + "@img/sharp-libvips-darwin-x64": "1.2.0", + "@img/sharp-libvips-linux-arm": "1.2.0", + "@img/sharp-libvips-linux-arm64": "1.2.0", + "@img/sharp-libvips-linux-ppc64": "1.2.0", + "@img/sharp-libvips-linux-s390x": "1.2.0", + "@img/sharp-libvips-linux-x64": "1.2.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", + "@img/sharp-libvips-linuxmusl-x64": "1.2.0", + "@img/sharp-linux-arm": "0.34.3", + "@img/sharp-linux-arm64": "0.34.3", + "@img/sharp-linux-ppc64": "0.34.3", + "@img/sharp-linux-s390x": "0.34.3", + "@img/sharp-linux-x64": "0.34.3", + "@img/sharp-linuxmusl-arm64": "0.34.3", + "@img/sharp-linuxmusl-x64": "0.34.3", + "@img/sharp-wasm32": "0.34.3", + "@img/sharp-win32-arm64": "0.34.3", + "@img/sharp-win32-ia32": "0.34.3", + "@img/sharp-win32-x64": "0.34.3" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9957,6 +10802,60 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -10133,6 +11032,15 @@ "node": ">=8" } }, + "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/strnum": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", @@ -10247,6 +11155,34 @@ "node": ">=6" } }, + "node_modules/tar-fs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "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/teeny-request": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.0.tgz", @@ -10834,6 +11770,18 @@ "@esbuild/win32-x64": "0.23.1" } }, + "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/tw-animate-css": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.5.tgz", diff --git a/package.json b/package.json index 860b28d..c89450e 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@types/bcryptjs": "^2.4.6", "@types/memoizee": "^0.4.12", "@types/multer": "^2.0.0", + "@types/node-fetch": "^2.6.13", "@types/video.js": "^7.3.58", "@uppy/aws-s3": "^4.3.2", "@uppy/core": "^4.5.2", @@ -56,6 +57,7 @@ "@uppy/progress-bar": "^4.3.2", "@uppy/react": "^4.5.2", "bcryptjs": "^3.0.2", + "canvas": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -67,6 +69,7 @@ "embla-carousel-react": "^8.6.0", "express": "^4.21.2", "express-session": "^1.18.2", + "face-api.js": "^0.22.2", "framer-motion": "^11.13.1", "google-auth-library": "^10.2.1", "hls.js": "^1.6.7", @@ -76,6 +79,7 @@ "memorystore": "^1.6.7", "multer": "^2.0.2", "next-themes": "^0.4.6", + "node-fetch": "^3.3.2", "openid-client": "^6.6.3", "passport": "^0.7.0", "passport-local": "^1.0.0", @@ -87,6 +91,7 @@ "react-resizable-panels": "^2.1.7", "react-share": "^5.2.2", "recharts": "^2.15.2", + "sharp": "^0.34.3", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1ee6dec --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "repl-nix-workspace" +version = "0.1.0" +description = "Add your description here" +requires-python = ">=3.11" +dependencies = [ + "mediapipe>=0.10.21", + "opencv-python>=4.11.0.86", + "pillow>=11.3.0", +] diff --git a/server/face-detection-js.ts b/server/face-detection-js.ts new file mode 100644 index 0000000..45233b5 --- /dev/null +++ b/server/face-detection-js.ts @@ -0,0 +1,258 @@ +import * as faceapi from 'face-api.js'; +import * as canvas from 'canvas'; +import sharp from 'sharp'; +import path from 'path'; +import { promises as fs } from 'fs'; + +// Setup face-api.js for Node.js environment +const { Canvas, Image, ImageData } = canvas; +// @ts-ignore +faceapi.env.monkeyPatch({ Canvas, Image, ImageData }); + +export interface FaceDetectionResult { + success: boolean; + faces_detected: number; + primary_face_confidence: number; + crop_info: { + x: number; + y: number; + width: number; + height: number; + }; + original_dimensions: { + width: number; + height: number; + }; + processing_strategy: 'face_centered' | 'center_crop'; + error?: string; +} + +export class JavaScriptFaceDetectionService { + private modelsLoaded = false; + + constructor() { + this.loadModels(); + } + + private async loadModels(): Promise { + if (this.modelsLoaded) return; + + try { + // Load face detection models from face-api.js + // Using smaller models for better performance + await faceapi.nets.tinyFaceDetector.loadFromDisk('./node_modules/face-api.js/weights'); + await faceapi.nets.faceLandmark68TinyNet.loadFromDisk('./node_modules/face-api.js/weights'); + + this.modelsLoaded = true; + console.log('✅ Face detection models loaded successfully'); + } catch (error) { + console.error('❌ Failed to load face detection models:', error); + // Fallback: continue without face detection + } + } + + private async downloadImage(imageUrl: string): Promise<{ buffer: Buffer; width: number; height: number }> { + // Use Node.js built-in fetch (available from Node 18+) + const response = await fetch(imageUrl); + if (!response.ok) { + throw new Error(`Failed to download image: ${response.statusText}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const metadata = await sharp(buffer).metadata(); + + return { + buffer, + width: metadata.width || 0, + height: metadata.height || 0 + }; + } + + private async detectFacesInImage(imageBuffer: Buffer): Promise { + if (!this.modelsLoaded) { + await this.loadModels(); + if (!this.modelsLoaded) { + return []; // Return empty array if models couldn't load + } + } + + try { + // Convert image buffer to canvas + const img = new Image(); + img.src = imageBuffer; + + // Wait for image to load + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + }); + + // Detect faces with tiny face detector for performance + const detections = await faceapi + .detectAllFaces(img, new faceapi.TinyFaceDetectorOptions()) + .withFaceLandmarks(true); + + return detections; + } catch (error) { + console.error('Error detecting faces:', error); + return []; + } + } + + private calculateFaceCenteredCrop( + imageWidth: number, + imageHeight: number, + faceBox: { x: number; y: number; width: number; height: number }, + targetAspect: number = 9/16 + ): { x: number; y: number; width: number; height: number } { + const faceCenterX = faceBox.x + faceBox.width / 2; + const faceCenterY = faceBox.y + faceBox.height / 2; + + let cropWidth: number, cropHeight: number, cropX: number, cropY: number; + + if (imageWidth / imageHeight > targetAspect) { + // Image is wider than target aspect ratio + cropWidth = Math.floor(imageHeight * targetAspect); + cropHeight = imageHeight; + cropX = Math.max(0, Math.min(faceCenterX - cropWidth / 2, imageWidth - cropWidth)); + cropY = 0; + } else { + // Image is taller than target aspect ratio + cropWidth = imageWidth; + cropHeight = Math.floor(imageWidth / targetAspect); + cropX = 0; + cropY = Math.max(0, Math.min(faceCenterY - cropHeight / 2, imageHeight - cropHeight)); + } + + return { x: cropX, y: cropY, width: cropWidth, height: cropHeight }; + } + + private calculateCenterCrop( + imageWidth: number, + imageHeight: number, + targetAspect: number = 9/16 + ): { x: number; y: number; width: number; height: number } { + let cropWidth: number, cropHeight: number, cropX: number, cropY: number; + + if (imageWidth / imageHeight > targetAspect) { + cropWidth = Math.floor(imageHeight * targetAspect); + cropHeight = imageHeight; + cropX = Math.floor((imageWidth - cropWidth) / 2); + cropY = 0; + } else { + cropWidth = imageWidth; + cropHeight = Math.floor(imageWidth / targetAspect); + cropX = 0; + cropY = Math.floor((imageHeight - cropHeight) / 2); + } + + return { x: cropX, y: cropY, width: cropWidth, height: cropHeight }; + } + + public async processThumbnail(thumbnailUrl: string): Promise { + try { + console.log(`🔍 Processing thumbnail: ${thumbnailUrl}`); + + // Download and analyze the image + const { buffer, width, height } = await this.downloadImage(thumbnailUrl); + + if (width === 0 || height === 0) { + throw new Error('Invalid image dimensions'); + } + + // Detect faces in the image + const faces = await this.detectFacesInImage(buffer); + + console.log(`👥 Found ${faces.length} faces in image`); + + let cropInfo: { x: number; y: number; width: number; height: number }; + let strategy: 'face_centered' | 'center_crop'; + let confidence = 0; + + if (faces.length > 0) { + // Use the first (most confident) face for centering + const primaryFace = faces[0]; + const faceBox = primaryFace.detection.box; + confidence = primaryFace.detection.score; + + cropInfo = this.calculateFaceCenteredCrop(width, height, faceBox); + strategy = 'face_centered'; + + console.log(`✅ Face-centered crop calculated with confidence: ${confidence.toFixed(2)}`); + } else { + // No faces detected, use center crop + cropInfo = this.calculateCenterCrop(width, height); + strategy = 'center_crop'; + + console.log(`📐 Center crop calculated (no faces detected)`); + } + + return { + success: true, + faces_detected: faces.length, + primary_face_confidence: confidence, + crop_info: cropInfo, + original_dimensions: { width, height }, + processing_strategy: strategy + }; + + } catch (error) { + console.error(`❌ Error processing thumbnail ${thumbnailUrl}:`, error); + return { + success: false, + faces_detected: 0, + primary_face_confidence: 0, + crop_info: { x: 0, y: 0, width: 0, height: 0 }, + original_dimensions: { width: 0, height: 0 }, + processing_strategy: 'center_crop', + error: error instanceof Error ? error.message : String(error) + }; + } + } + + public calculateObjectPosition( + cropInfo: { x: number; y: number; width: number; height: number }, + originalDimensions: { width: number; height: number } + ): string { + if (!cropInfo || !originalDimensions.width || !originalDimensions.height) { + return 'center center'; + } + + // Calculate the percentage position of the crop center + const cropCenterX = cropInfo.x + cropInfo.width / 2; + const cropCenterY = cropInfo.y + cropInfo.height / 2; + + const positionX = (cropCenterX / originalDimensions.width) * 100; + const positionY = (cropCenterY / originalDimensions.height) * 100; + + return `${Math.max(0, Math.min(100, positionX)).toFixed(1)}% ${Math.max(0, Math.min(100, positionY)).toFixed(1)}%`; + } + + public async getOptimizedThumbnailInfo(thumbnailUrl: string) { + try { + const result = await this.processThumbnail(thumbnailUrl); + + return { + originalUrl: thumbnailUrl, + faceCenterPosition: this.calculateObjectPosition(result.crop_info, result.original_dimensions), + facesDetected: result.faces_detected, + confidence: result.primary_face_confidence, + strategy: result.processing_strategy, + processedSuccessfully: result.success + }; + } catch (error) { + console.error('Error optimizing thumbnail:', error); + return { + originalUrl: thumbnailUrl, + faceCenterPosition: 'center center', + facesDetected: 0, + confidence: 0, + strategy: 'center_crop' as const, + processedSuccessfully: false + }; + } + } +} + +// Export singleton instance +export const jsFaceDetectionService = new JavaScriptFaceDetectionService(); \ No newline at end of file diff --git a/server/face-detection-service.ts b/server/face-detection-service.ts new file mode 100644 index 0000000..5c2109d --- /dev/null +++ b/server/face-detection-service.ts @@ -0,0 +1,145 @@ +import { spawn } from 'child_process'; +import path from 'path'; + +export interface FaceDetectionResult { + success: boolean; + faces_detected: number; + primary_face_confidence: number; + crop_info: { + x: number; + y: number; + width: number; + height: number; + }; + original_dimensions: { + width: number; + height: number; + }; + processed_image?: string; + processing_strategy: 'face_centered' | 'center_crop'; + error?: string; +} + +export class FaceDetectionService { + private pythonScriptPath: string; + + constructor() { + this.pythonScriptPath = path.join(__dirname, 'face-detection.py'); + } + + /** + * Process thumbnail URL to detect faces and create centered crop + */ + async processThumbnail(thumbnailUrl: string): Promise { + return new Promise((resolve, reject) => { + const python = spawn('python3', [this.pythonScriptPath, thumbnailUrl]); + + let stdoutData = ''; + let stderrData = ''; + + python.stdout.on('data', (data) => { + stdoutData += data.toString(); + }); + + python.stderr.on('data', (data) => { + stderrData += data.toString(); + }); + + python.on('close', (code) => { + if (code !== 0) { + console.error(`Face detection script exited with code ${code}`); + console.error('stderr:', stderrData); + resolve({ + success: false, + faces_detected: 0, + primary_face_confidence: 0, + crop_info: { x: 0, y: 0, width: 0, height: 0 }, + original_dimensions: { width: 0, height: 0 }, + processing_strategy: 'center_crop', + error: `Script exited with code ${code}: ${stderrData}` + }); + return; + } + + try { + const result = JSON.parse(stdoutData); + resolve(result); + } catch (error) { + console.error('Error parsing face detection result:', error); + console.error('stdout:', stdoutData); + resolve({ + success: false, + faces_detected: 0, + primary_face_confidence: 0, + crop_info: { x: 0, y: 0, width: 0, height: 0 }, + original_dimensions: { width: 0, height: 0 }, + processing_strategy: 'center_crop', + error: `Failed to parse result: ${error}` + }); + } + }); + + python.on('error', (error) => { + console.error('Failed to start face detection script:', error); + resolve({ + success: false, + faces_detected: 0, + primary_face_confidence: 0, + crop_info: { x: 0, y: 0, width: 0, height: 0 }, + original_dimensions: { width: 0, height: 0 }, + processing_strategy: 'center_crop', + error: `Failed to start script: ${error.message}` + }); + }); + }); + } + + /** + * Calculate CSS object-position for face-centered display + */ + calculateObjectPosition(cropInfo: FaceDetectionResult['crop_info'], originalDimensions: { width: number; height: number }): string { + if (!cropInfo || !originalDimensions.width || !originalDimensions.height) { + return 'center center'; + } + + // Calculate the percentage position of the crop center + const cropCenterX = cropInfo.x + cropInfo.width / 2; + const cropCenterY = cropInfo.y + cropInfo.height / 2; + + const positionX = (cropCenterX / originalDimensions.width) * 100; + const positionY = (cropCenterY / originalDimensions.height) * 100; + + return `${Math.max(0, Math.min(100, positionX))}% ${Math.max(0, Math.min(100, positionY))}%`; + } + + /** + * Get optimized thumbnail URL with face detection applied + */ + async getOptimizedThumbnailInfo(thumbnailUrl: string) { + try { + const result = await this.processThumbnail(thumbnailUrl); + + return { + originalUrl: thumbnailUrl, + faceCenterPosition: this.calculateObjectPosition(result.crop_info, result.original_dimensions), + facesDetected: result.faces_detected, + confidence: result.primary_face_confidence, + strategy: result.processing_strategy, + processedSuccessfully: result.success + }; + } catch (error) { + console.error('Error optimizing thumbnail:', error); + return { + originalUrl: thumbnailUrl, + faceCenterPosition: 'center center', + facesDetected: 0, + confidence: 0, + strategy: 'center_crop' as const, + processedSuccessfully: false + }; + } + } +} + +// Export singleton instance +export const faceDetectionService = new FaceDetectionService(); \ No newline at end of file diff --git a/server/face-detection.py b/server/face-detection.py new file mode 100644 index 0000000..e5e5625 --- /dev/null +++ b/server/face-detection.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +""" +Face Detection Service for go4.video platform +Automatically detects faces in thumbnails and creates centered crops +""" + +import cv2 +import mediapipe as mp +import numpy as np +from PIL import Image, ImageOps +import io +import base64 +import sys +import json +import requests +from typing import Tuple, Optional, Dict, Any +import logging + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +class FaceDetectionService: + def __init__(self): + """Initialize MediaPipe face detection""" + self.mp_face_detection = mp.solutions.face_detection + self.mp_drawing = mp.solutions.drawing_utils + self.face_detection = self.mp_face_detection.FaceDetection( + model_selection=0, # 0 for short-range (2 meters), 1 for full-range (5 meters) + min_detection_confidence=0.5 + ) + + def detect_faces(self, image_array: np.ndarray) -> list: + """ + Detect faces in image and return bounding boxes + + Args: + image_array: OpenCV image array (BGR format) + + Returns: + List of face detection results with bounding boxes + """ + try: + # Convert BGR to RGB for MediaPipe + rgb_image = cv2.cvtColor(image_array, cv2.COLOR_BGR2RGB) + + # Perform face detection + results = self.face_detection.process(rgb_image) + + if not results.detections: + return [] + + faces = [] + height, width = image_array.shape[:2] + + for detection in results.detections: + bbox = detection.location_data.relative_bounding_box + confidence = detection.score[0] + + # Convert relative coordinates to absolute + x = int(bbox.xmin * width) + y = int(bbox.ymin * height) + w = int(bbox.width * width) + h = int(bbox.height * height) + + faces.append({ + 'x': x, + 'y': y, + 'width': w, + 'height': h, + 'confidence': confidence, + 'center_x': x + w // 2, + 'center_y': y + h // 2 + }) + + # Sort by confidence (highest first) + faces.sort(key=lambda f: f['confidence'], reverse=True) + return faces + + except Exception as e: + logger.error(f"Error detecting faces: {e}") + return [] + + def get_face_centered_crop_position(self, image_width: int, image_height: int, + faces: list, target_aspect: float = 9/16) -> Dict[str, int]: + """ + Calculate optimal crop position to center the most prominent face + + Args: + image_width: Original image width + image_height: Original image height + faces: List of detected faces + target_aspect: Target aspect ratio (default 9:16 for portrait) + + Returns: + Dict with crop coordinates: {x, y, width, height} + """ + if not faces: + # No faces detected, return center crop + if image_width / image_height > target_aspect: + # Image is wider, crop from center horizontally + crop_width = int(image_height * target_aspect) + crop_height = image_height + crop_x = (image_width - crop_width) // 2 + crop_y = 0 + else: + # Image is taller, crop from center vertically + crop_width = image_width + crop_height = int(image_width / target_aspect) + crop_x = 0 + crop_y = (image_height - crop_height) // 2 + + return { + 'x': crop_x, + 'y': crop_y, + 'width': crop_width, + 'height': crop_height + } + + # Use the most confident face + primary_face = faces[0] + face_center_x = primary_face['center_x'] + face_center_y = primary_face['center_y'] + + # Calculate crop dimensions based on target aspect ratio + if image_width / image_height > target_aspect: + # Image is wider than target, crop horizontally + crop_width = int(image_height * target_aspect) + crop_height = image_height + + # Center crop on face horizontally + crop_x = face_center_x - crop_width // 2 + crop_x = max(0, min(crop_x, image_width - crop_width)) + crop_y = 0 + + else: + # Image is taller than target, crop vertically + crop_width = image_width + crop_height = int(image_width / target_aspect) + + # Center crop on face vertically + crop_y = face_center_y - crop_height // 2 + crop_y = max(0, min(crop_y, image_height - crop_height)) + crop_x = 0 + + return { + 'x': crop_x, + 'y': crop_y, + 'width': crop_width, + 'height': crop_height + } + + def process_thumbnail_url(self, thumbnail_url: str, target_width: int = 300, + target_height: int = 533) -> Optional[Dict[str, Any]]: + """ + Download, process thumbnail URL and return face detection results + + Args: + thumbnail_url: URL of the thumbnail image + target_width: Target width for the processed image + target_height: Target height for the processed image + + Returns: + Dict with face detection results and processing info + """ + try: + # Download image + response = requests.get(thumbnail_url, timeout=10) + response.raise_for_status() + + # Convert to OpenCV format + image_array = np.frombuffer(response.content, np.uint8) + image = cv2.imdecode(image_array, cv2.IMREAD_COLOR) + + if image is None: + logger.error(f"Failed to decode image from URL: {thumbnail_url}") + return None + + height, width = image.shape[:2] + + # Detect faces + faces = self.detect_faces(image) + + # Get optimal crop position for face centering + crop_info = self.get_face_centered_crop_position( + width, height, faces, target_height / target_width + ) + + # Create face-centered crop + cropped_image = image[ + crop_info['y']:crop_info['y'] + crop_info['height'], + crop_info['x']:crop_info['x'] + crop_info['width'] + ] + + # Resize to target dimensions + resized_image = cv2.resize(cropped_image, (target_width, target_height)) + + # Convert to base64 for web usage + _, buffer = cv2.imencode('.jpg', resized_image, [cv2.IMWRITE_JPEG_QUALITY, 85]) + processed_image_b64 = base64.b64encode(buffer).decode('utf-8') + + return { + 'success': True, + 'faces_detected': len(faces), + 'primary_face_confidence': faces[0]['confidence'] if faces else 0, + 'crop_info': crop_info, + 'original_dimensions': {'width': width, 'height': height}, + 'processed_image': f"data:image/jpeg;base64,{processed_image_b64}", + 'processing_strategy': 'face_centered' if faces else 'center_crop' + } + + except Exception as e: + logger.error(f"Error processing thumbnail URL {thumbnail_url}: {e}") + return { + 'success': False, + 'error': str(e), + 'faces_detected': 0 + } + +def main(): + """Main function for CLI usage""" + if len(sys.argv) < 2: + print("Usage: python3 face-detection.py ") + sys.exit(1) + + thumbnail_url = sys.argv[1] + service = FaceDetectionService() + result = service.process_thumbnail_url(thumbnail_url) + + # Output JSON result + print(json.dumps(result, indent=2)) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index d32ab75..ee873cf 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -222,6 +222,107 @@ export async function registerRoutes(app: Express): Promise { } }); + // Face detection endpoint for thumbnails + app.post("/api/videos/:id/analyze-face", async (req, res) => { + try { + const { id } = req.params; + const video = await storage.getVideo(id); + + if (!video) { + return res.status(404).json({ message: "Video not found" }); + } + + if (!video.thumbnailUrl) { + return res.status(400).json({ message: "Video has no thumbnail" }); + } + + // Import smart thumbnail service (no external dependencies) + const { smartThumbnailService } = await import("./smart-thumbnail-service"); + const result = await smartThumbnailService.getOptimizedThumbnailInfo(video.thumbnailUrl); + + // Update video with face position data + await storage.updateVideo(id, { + faceCenterPosition: result.faceCenterPosition, + facesDetected: result.facesDetected, + faceConfidence: result.confidence + }); + + res.json({ + videoId: id, + ...result + }); + } catch (error) { + console.error("Error analyzing face in thumbnail:", error); + res.status(500).json({ message: "Failed to analyze face" }); + } + }); + + // Batch face analysis for all videos + app.post("/api/videos/batch-analyze-faces", async (req, res) => { + try { + // Get all videos that don't have face analysis yet + const allVideos = await storage.getVideos(1000, 0); // Get up to 1000 videos + const videosToProcess = allVideos.filter(video => !video.faceCenterPosition && video.thumbnailUrl); + + if (videosToProcess.length === 0) { + return res.json({ + message: "All videos already processed or no thumbnails available", + processed: 0, + total: allVideos.length + }); + } + + console.log(`Starting batch face analysis for ${videosToProcess.length} videos...`); + + // Import smart thumbnail service (no external dependencies) + const { smartThumbnailService } = await import("./smart-thumbnail-service"); + let processed = 0; + let failed = 0; + + // Process videos in batches to avoid overwhelming the system + const batchSize = 5; + for (let i = 0; i < videosToProcess.length; i += batchSize) { + const batch = videosToProcess.slice(i, i + batchSize); + + await Promise.all(batch.map(async (video) => { + try { + console.log(`Processing face detection for video: ${video.title} (${video.id})`); + const result = await smartThumbnailService.getOptimizedThumbnailInfo(video.thumbnailUrl); + + await storage.updateVideo(video.id, { + faceCenterPosition: result.faceCenterPosition, + facesDetected: result.facesDetected, + faceConfidence: result.confidence + }); + + processed++; + console.log(`✅ Face analysis completed for ${video.title} - Faces detected: ${result.facesDetected}`); + } catch (error) { + console.error(`❌ Face analysis failed for ${video.title}:`, error); + failed++; + } + })); + + // Small delay between batches to be respectful + if (i + batchSize < videosToProcess.length) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + console.log(`Batch face analysis completed: ${processed} processed, ${failed} failed`); + + res.json({ + message: "Batch face analysis completed", + processed, + failed, + total: videosToProcess.length + }); + } catch (error) { + console.error("Error in batch face analysis:", error); + res.status(500).json({ message: "Failed to perform batch face analysis" }); + } + }); + // Get single video by ID app.get("/api/videos/:id", async (req, res) => { try { diff --git a/server/simple-face-detection.ts b/server/simple-face-detection.ts new file mode 100644 index 0000000..11524f6 --- /dev/null +++ b/server/simple-face-detection.ts @@ -0,0 +1,322 @@ +import sharp from 'sharp'; + +export interface SimpleFaceDetectionResult { + success: boolean; + faces_detected: number; + primary_face_confidence: number; + crop_info: { + x: number; + y: number; + width: number; + height: number; + }; + original_dimensions: { + width: number; + height: number; + }; + processing_strategy: 'face_centered' | 'center_crop' | 'smart_crop'; + error?: string; +} + +export class SimpleFaceDetectionService { + /** + * Download image and get basic info + */ + private async downloadImage(imageUrl: string): Promise<{ buffer: Buffer; width: number; height: number }> { + try { + const response = await fetch(imageUrl); + if (!response.ok) { + throw new Error(`Failed to download image: ${response.statusText}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const metadata = await sharp(buffer).metadata(); + + return { + buffer, + width: metadata.width || 0, + height: metadata.height || 0 + }; + } catch (error) { + throw new Error(`Image download failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Analyze image to find interesting areas (likely faces or main subjects) + * Uses edge detection and high contrast areas to identify focal points + */ + private async analyzeImageFocalPoints(imageBuffer: Buffer, width: number, height: number): Promise<{ x: number; y: number; confidence: number }[]> { + try { + // Resize image for faster processing + const resizedBuffer = await sharp(imageBuffer) + .resize(Math.min(400, width), Math.min(400, height), { fit: 'inside' }) + .greyscale() + .raw() + .toBuffer({ resolveWithObject: true }); + + const { data, info } = resizedBuffer; + const { width: resizedWidth, height: resizedHeight } = info; + + // Simple edge detection to find areas of interest + const focalPoints: { x: number; y: number; confidence: number }[] = []; + const blockSize = 20; // Analyze in 20x20 pixel blocks + + for (let y = 0; y < resizedHeight - blockSize; y += blockSize) { + for (let x = 0; x < resizedWidth - blockSize; x += blockSize) { + let edgeStrength = 0; + let averageBrightness = 0; + let pixelCount = 0; + + // Analyze block for edge strength and brightness + for (let dy = 0; dy < blockSize; dy++) { + for (let dx = 0; dx < blockSize; dx++) { + const currentY = y + dy; + const currentX = x + dx; + + if (currentY < resizedHeight && currentX < resizedWidth) { + const idx = currentY * resizedWidth + currentX; + const currentPixel = data[idx]; + averageBrightness += currentPixel; + pixelCount++; + + // Simple edge detection (horizontal and vertical gradients) + if (currentX < resizedWidth - 1 && currentY < resizedHeight - 1) { + const rightPixel = data[currentY * resizedWidth + (currentX + 1)]; + const downPixel = data[(currentY + 1) * resizedWidth + currentX]; + + const horizontalGradient = Math.abs(currentPixel - rightPixel); + const verticalGradient = Math.abs(currentPixel - downPixel); + edgeStrength += horizontalGradient + verticalGradient; + } + } + } + } + + averageBrightness /= pixelCount; + + // Calculate confidence based on edge strength and optimal brightness + // Faces typically have moderate brightness (not too dark, not too bright) + const brightnessScore = 1 - Math.abs(averageBrightness - 128) / 128; + const edgeScore = Math.min(edgeStrength / (blockSize * blockSize * 100), 1); + const confidence = (brightnessScore * 0.3 + edgeScore * 0.7); + + if (confidence > 0.3) { // Only consider blocks with reasonable confidence + // Convert back to original image coordinates + const originalX = (x / resizedWidth) * width; + const originalY = (y / resizedHeight) * height; + + focalPoints.push({ + x: originalX, + y: originalY, + confidence + }); + } + } + } + + // Sort by confidence and return top candidates + return focalPoints + .sort((a, b) => b.confidence - a.confidence) + .slice(0, 3); // Keep top 3 focal points + + } catch (error) { + console.error('Error analyzing image focal points:', error); + return []; + } + } + + /** + * Calculate smart crop based on focal points + */ + private calculateSmartCrop( + imageWidth: number, + imageHeight: number, + focalPoints: { x: number; y: number; confidence: number }[], + targetAspect: number = 9/16 + ): { x: number; y: number; width: number; height: number } { + + if (focalPoints.length === 0) { + // No focal points found, use center crop + return this.calculateCenterCrop(imageWidth, imageHeight, targetAspect); + } + + // Use the highest confidence focal point + const primaryFocalPoint = focalPoints[0]; + + let cropWidth: number, cropHeight: number, cropX: number, cropY: number; + + if (imageWidth / imageHeight > targetAspect) { + // Image is wider than target aspect ratio + cropWidth = Math.floor(imageHeight * targetAspect); + cropHeight = imageHeight; + + // Center crop horizontally around focal point + cropX = Math.max(0, Math.min( + primaryFocalPoint.x - cropWidth / 2, + imageWidth - cropWidth + )); + cropY = 0; + } else { + // Image is taller than target aspect ratio + cropWidth = imageWidth; + cropHeight = Math.floor(imageWidth / targetAspect); + + // Center crop vertically around focal point + cropY = Math.max(0, Math.min( + primaryFocalPoint.y - cropHeight / 2, + imageHeight - cropHeight + )); + cropX = 0; + } + + return { x: cropX, y: cropY, width: cropWidth, height: cropHeight }; + } + + /** + * Calculate standard center crop + */ + private calculateCenterCrop( + imageWidth: number, + imageHeight: number, + targetAspect: number = 9/16 + ): { x: number; y: number; width: number; height: number } { + let cropWidth: number, cropHeight: number, cropX: number, cropY: number; + + if (imageWidth / imageHeight > targetAspect) { + cropWidth = Math.floor(imageHeight * targetAspect); + cropHeight = imageHeight; + cropX = Math.floor((imageWidth - cropWidth) / 2); + cropY = 0; + } else { + cropWidth = imageWidth; + cropHeight = Math.floor(imageWidth / targetAspect); + cropX = 0; + cropY = Math.floor((imageHeight - cropHeight) / 2); + } + + return { x: cropX, y: cropY, width: cropWidth, height: cropHeight }; + } + + /** + * Process thumbnail image to find optimal crop position + */ + public async processThumbnail(thumbnailUrl: string): Promise { + try { + console.log(`🔍 Analyzing thumbnail: ${thumbnailUrl}`); + + // Download and analyze the image + const { buffer, width, height } = await this.downloadImage(thumbnailUrl); + + if (width === 0 || height === 0) { + throw new Error('Invalid image dimensions'); + } + + // Analyze image for focal points (potential faces/subjects) + const focalPoints = await this.analyzeImageFocalPoints(buffer, width, height); + + console.log(`🎯 Found ${focalPoints.length} focal points`); + + let cropInfo: { x: number; y: number; width: number; height: number }; + let strategy: 'face_centered' | 'center_crop' | 'smart_crop'; + let confidence = 0; + let facesDetected = 0; + + if (focalPoints.length > 0) { + const primaryFocalPoint = focalPoints[0]; + confidence = primaryFocalPoint.confidence; + + // Consider it a "face" if confidence is high enough + if (confidence > 0.6) { + facesDetected = 1; + strategy = 'face_centered'; + } else { + strategy = 'smart_crop'; + } + + cropInfo = this.calculateSmartCrop(width, height, focalPoints); + + console.log(`✅ Smart crop calculated with confidence: ${confidence.toFixed(2)}`); + } else { + // No focal points found, use center crop + cropInfo = this.calculateCenterCrop(width, height); + strategy = 'center_crop'; + + console.log(`📐 Center crop calculated (no focal points detected)`); + } + + return { + success: true, + faces_detected: facesDetected, + primary_face_confidence: confidence, + crop_info: cropInfo, + original_dimensions: { width, height }, + processing_strategy: strategy + }; + + } catch (error) { + console.error(`❌ Error processing thumbnail ${thumbnailUrl}:`, error); + return { + success: false, + faces_detected: 0, + primary_face_confidence: 0, + crop_info: { x: 0, y: 0, width: 0, height: 0 }, + original_dimensions: { width: 0, height: 0 }, + processing_strategy: 'center_crop', + error: error instanceof Error ? error.message : String(error) + }; + } + } + + /** + * Calculate CSS object-position for optimal display + */ + public calculateObjectPosition( + cropInfo: { x: number; y: number; width: number; height: number }, + originalDimensions: { width: number; height: number } + ): string { + if (!cropInfo || !originalDimensions.width || !originalDimensions.height) { + return 'center center'; + } + + // Calculate the percentage position of the crop center + const cropCenterX = cropInfo.x + cropInfo.width / 2; + const cropCenterY = cropInfo.y + cropInfo.height / 2; + + const positionX = (cropCenterX / originalDimensions.width) * 100; + const positionY = (cropCenterY / originalDimensions.height) * 100; + + return `${Math.max(0, Math.min(100, positionX)).toFixed(1)}% ${Math.max(0, Math.min(100, positionY)).toFixed(1)}%`; + } + + /** + * Get optimized thumbnail information + */ + public async getOptimizedThumbnailInfo(thumbnailUrl: string) { + try { + const result = await this.processThumbnail(thumbnailUrl); + + return { + originalUrl: thumbnailUrl, + faceCenterPosition: this.calculateObjectPosition(result.crop_info, result.original_dimensions), + facesDetected: result.faces_detected, + confidence: result.primary_face_confidence, + strategy: result.processing_strategy, + processedSuccessfully: result.success + }; + } catch (error) { + console.error('Error optimizing thumbnail:', error); + return { + originalUrl: thumbnailUrl, + faceCenterPosition: 'center center', + facesDetected: 0, + confidence: 0, + strategy: 'center_crop' as const, + processedSuccessfully: false + }; + } + } +} + +// Export singleton instance +export const simpleFaceDetectionService = new SimpleFaceDetectionService(); \ No newline at end of file diff --git a/server/smart-thumbnail-service.ts b/server/smart-thumbnail-service.ts new file mode 100644 index 0000000..ef29a9b --- /dev/null +++ b/server/smart-thumbnail-service.ts @@ -0,0 +1,316 @@ +import sharp from 'sharp'; + +export interface SmartThumbnailResult { + success: boolean; + faces_detected: number; + primary_face_confidence: number; + crop_info: { + x: number; + y: number; + width: number; + height: number; + }; + original_dimensions: { + width: number; + height: number; + }; + processing_strategy: 'face_centered' | 'center_crop' | 'smart_crop'; + error?: string; +} + +export class SmartThumbnailService { + /** + * Download image and get metadata + */ + private async downloadImage(imageUrl: string): Promise<{ sharpInstance: sharp.Sharp; width: number; height: number }> { + try { + const response = await fetch(imageUrl); + if (!response.ok) { + throw new Error(`Failed to download image: ${response.statusText}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const sharpInstance = sharp(buffer); + const metadata = await sharpInstance.metadata(); + + return { + sharpInstance, + width: metadata.width || 0, + height: metadata.height || 0 + }; + } catch (error) { + throw new Error(`Image download failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Analyze image using Sharp's built-in stats to find areas of interest + */ + private async analyzeImageContent(sharpInstance: sharp.Sharp, width: number, height: number): Promise<{ x: number; y: number; confidence: number }[]> { + try { + const focalPoints: { x: number; y: number; confidence: number }[] = []; + + // Analyze different regions of the image + const gridSize = 3; // 3x3 grid + const regionWidth = Math.floor(width / gridSize); + const regionHeight = Math.floor(height / gridSize); + + for (let row = 0; row < gridSize; row++) { + for (let col = 0; col < gridSize; col++) { + const x = col * regionWidth; + const y = row * regionHeight; + + try { + // Extract region and analyze + const regionStats = await sharpInstance + .clone() + .extract({ left: x, top: y, width: regionWidth, height: regionHeight }) + .stats(); + + // Calculate region score based on contrast and brightness distribution + let regionScore = 0; + + if (regionStats.channels && regionStats.channels.length > 0) { + const channel = regionStats.channels[0]; // Use first channel (or grayscale) + + // Good regions typically have: + // 1. Moderate mean brightness (not too dark/bright) + // 2. Good standard deviation (contrast) + // 3. Balanced distribution + + const meanBrightness = channel.mean; + const contrast = channel.std; + + // Score brightness (prefer middle range 80-180) + const brightnessScore = meanBrightness > 80 && meanBrightness < 180 ? + 1 - Math.abs(meanBrightness - 130) / 130 : 0.2; + + // Score contrast (higher is better, up to a point) + const contrastScore = Math.min(contrast / 50, 1); + + // Combine scores + regionScore = (brightnessScore * 0.4 + contrastScore * 0.6); + + // Boost center regions slightly (faces often in center) + if (row === 1 && col === 1) { + regionScore *= 1.2; + } + + // Boost upper-center region (faces often in upper portion) + if (row === 0 && col === 1) { + regionScore *= 1.1; + } + } + + if (regionScore > 0.3) { + focalPoints.push({ + x: x + regionWidth / 2, // Center of region + y: y + regionHeight / 2, + confidence: regionScore + }); + } + } catch (regionError) { + // Skip this region if analysis fails + console.warn(`Failed to analyze region ${row},${col}:`, regionError); + } + } + } + + // Sort by confidence + return focalPoints.sort((a, b) => b.confidence - a.confidence); + + } catch (error) { + console.error('Error analyzing image content:', error); + return []; + } + } + + /** + * Calculate smart crop based on focal points + */ + private calculateSmartCrop( + imageWidth: number, + imageHeight: number, + focalPoints: { x: number; y: number; confidence: number }[], + targetAspect: number = 9/16 + ): { x: number; y: number; width: number; height: number } { + + if (focalPoints.length === 0) { + return this.calculateCenterCrop(imageWidth, imageHeight, targetAspect); + } + + const primaryFocalPoint = focalPoints[0]; + + let cropWidth: number, cropHeight: number, cropX: number, cropY: number; + + if (imageWidth / imageHeight > targetAspect) { + // Image is wider - crop horizontally + cropWidth = Math.floor(imageHeight * targetAspect); + cropHeight = imageHeight; + + // Center crop around focal point, but keep within bounds + cropX = Math.max(0, Math.min( + Math.floor(primaryFocalPoint.x - cropWidth / 2), + imageWidth - cropWidth + )); + cropY = 0; + } else { + // Image is taller - crop vertically + cropWidth = imageWidth; + cropHeight = Math.floor(imageWidth / targetAspect); + + // Center crop around focal point vertically + cropY = Math.max(0, Math.min( + Math.floor(primaryFocalPoint.y - cropHeight / 2), + imageHeight - cropHeight + )); + cropX = 0; + } + + return { x: cropX, y: cropY, width: cropWidth, height: cropHeight }; + } + + /** + * Calculate standard center crop + */ + private calculateCenterCrop( + imageWidth: number, + imageHeight: number, + targetAspect: number = 9/16 + ): { x: number; y: number; width: number; height: number } { + let cropWidth: number, cropHeight: number, cropX: number, cropY: number; + + if (imageWidth / imageHeight > targetAspect) { + cropWidth = Math.floor(imageHeight * targetAspect); + cropHeight = imageHeight; + cropX = Math.floor((imageWidth - cropWidth) / 2); + cropY = 0; + } else { + cropWidth = imageWidth; + cropHeight = Math.floor(imageWidth / targetAspect); + cropX = 0; + cropY = Math.floor((imageHeight - cropHeight) / 2); + } + + return { x: cropX, y: cropY, width: cropWidth, height: cropHeight }; + } + + /** + * Process thumbnail to find optimal crop position + */ + public async processThumbnail(thumbnailUrl: string): Promise { + try { + console.log(`🔍 Analyzing thumbnail: ${thumbnailUrl}`); + + const { sharpInstance, width, height } = await this.downloadImage(thumbnailUrl); + + if (width === 0 || height === 0) { + throw new Error('Invalid image dimensions'); + } + + // Analyze image content for focal points + const focalPoints = await this.analyzeImageContent(sharpInstance, width, height); + + console.log(`🎯 Found ${focalPoints.length} focal points`); + + let cropInfo: { x: number; y: number; width: number; height: number }; + let strategy: 'face_centered' | 'center_crop' | 'smart_crop'; + let confidence = 0; + let facesDetected = 0; + + if (focalPoints.length > 0) { + const primaryFocalPoint = focalPoints[0]; + confidence = primaryFocalPoint.confidence; + + // Consider high-confidence focal points as potential faces + if (confidence > 0.7) { + facesDetected = 1; + strategy = 'face_centered'; + } else { + strategy = 'smart_crop'; + } + + cropInfo = this.calculateSmartCrop(width, height, focalPoints); + + console.log(`✅ Smart crop calculated with confidence: ${confidence.toFixed(2)}`); + } else { + cropInfo = this.calculateCenterCrop(width, height); + strategy = 'center_crop'; + + console.log(`📐 Center crop calculated (no focal points detected)`); + } + + return { + success: true, + faces_detected: facesDetected, + primary_face_confidence: confidence, + crop_info: cropInfo, + original_dimensions: { width, height }, + processing_strategy: strategy + }; + + } catch (error) { + console.error(`❌ Error processing thumbnail ${thumbnailUrl}:`, error); + return { + success: false, + faces_detected: 0, + primary_face_confidence: 0, + crop_info: { x: 0, y: 0, width: 0, height: 0 }, + original_dimensions: { width: 0, height: 0 }, + processing_strategy: 'center_crop', + error: error instanceof Error ? error.message : String(error) + }; + } + } + + /** + * Calculate CSS object-position + */ + public calculateObjectPosition( + cropInfo: { x: number; y: number; width: number; height: number }, + originalDimensions: { width: number; height: number } + ): string { + if (!cropInfo || !originalDimensions.width || !originalDimensions.height) { + return 'center center'; + } + + const cropCenterX = cropInfo.x + cropInfo.width / 2; + const cropCenterY = cropInfo.y + cropInfo.height / 2; + + const positionX = (cropCenterX / originalDimensions.width) * 100; + const positionY = (cropCenterY / originalDimensions.height) * 100; + + return `${Math.max(0, Math.min(100, positionX)).toFixed(1)}% ${Math.max(0, Math.min(100, positionY)).toFixed(1)}%`; + } + + /** + * Get optimized thumbnail information + */ + public async getOptimizedThumbnailInfo(thumbnailUrl: string) { + try { + const result = await this.processThumbnail(thumbnailUrl); + + return { + originalUrl: thumbnailUrl, + faceCenterPosition: this.calculateObjectPosition(result.crop_info, result.original_dimensions), + facesDetected: result.faces_detected, + confidence: result.primary_face_confidence, + strategy: result.processing_strategy, + processedSuccessfully: result.success + }; + } catch (error) { + console.error('Error optimizing thumbnail:', error); + return { + originalUrl: thumbnailUrl, + faceCenterPosition: 'center center', + facesDetected: 0, + confidence: 0, + strategy: 'center_crop' as const, + processedSuccessfully: false + }; + } + } +} + +// Export singleton instance +export const smartThumbnailService = new SmartThumbnailService(); \ No newline at end of file diff --git a/server/storage.ts b/server/storage.ts index 690f75c..9d2c4aa 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -640,6 +640,7 @@ export class MemStorage implements IStorage { class BunnyStorage implements IStorage { private bunnyService: BunnyService; private viewsCache: Map = new Map(); + private faceDataCache: Map = new Map(); constructor() { this.bunnyService = new BunnyService(); @@ -649,7 +650,12 @@ class BunnyStorage implements IStorage { console.log(`Fetching videos from cache: limit=${limit}, offset=${offset}, search=${search}`); const result = videoSyncService.getVideos(limit, offset, search); console.log(`Returning ${result.videos.length} videos from cache (age: ${result.cacheAge}ms)`); - return result.videos; + + // Apply face detection data from cache + return result.videos.map(video => { + const faceData = this.faceDataCache.get(video.id); + return faceData ? { ...video, ...faceData } : video; + }); } async getVideo(id: string): Promise