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