diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b72df70d..d0bc9ff1 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,13 +8,23 @@
"name": "momshell-frontend",
"version": "1.0.0",
"dependencies": {
+ "@mediapipe/camera_utils": "^0.3.1675466862",
+ "@mediapipe/hands": "^0.4.1675469240",
"axios": "^1.13.6",
+ "gsap": "^3.14.2",
"pinia": "^3.0.4",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "three": "^0.183.2",
"vue": "^3.5.13"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^25.3.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@types/three": "^0.183.1",
+ "@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-vue": "^6.0.4",
"eslint": "^10.0.2",
"eslint-plugin-vue": "^10.8.0",
@@ -41,6 +51,164 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/core/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -59,6 +227,30 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/parser": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
@@ -74,6 +266,72 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
@@ -87,6 +345,13 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -725,12 +990,67 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@mediapipe/camera_utils": {
+ "version": "0.3.1675466862",
+ "resolved": "https://registry.npmjs.org/@mediapipe/camera_utils/-/camera_utils-0.3.1675466862.tgz",
+ "integrity": "sha512-siuXBoUxWo9WL0MeAxIxvxY04bvbtdNl7uCxoJxiAiRtNnCYrurr7Vl5VYQ94P7Sq0gVq6PxIDhWWeZ/pLnSzw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@mediapipe/hands": {
+ "version": "0.4.1675469240",
+ "resolved": "https://registry.npmjs.org/@mediapipe/hands/-/hands-0.4.1675469240.tgz",
+ "integrity": "sha512-GxoZvL1mmhJxFxjuyj7vnC++JIuInGznHBin5c7ZSq/RbcnGyfEcJrkM/bMu5K1Mz/2Ko+vEX6/+wewmEHPrHg==",
+ "license": "Apache-2.0"
+ },
"node_modules/@puppeteer/browsers": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz",
@@ -1117,6 +1437,58 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -1148,6 +1520,57 @@
"undici-types": "~7.18.0"
}
},
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.183.1",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
+ "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "@webgpu/types": "*",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~1.0.1"
+ }
+ },
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -1406,6 +1829,34 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.1.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
+ "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.18.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz",
@@ -1601,6 +2052,13 @@
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
"license": "MIT"
},
+ "node_modules/@webgpu/types": {
+ "version": "0.1.69",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
+ "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -1840,6 +2298,19 @@
"bare-path": "^3.0.0"
}
},
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/basic-ftp": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
@@ -1879,6 +2350,41 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -1912,6 +2418,27 @@
"node": ">=6"
}
},
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001777",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
+ "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
"node_modules/chromium-bidi": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz",
@@ -1973,6 +2500,13 @@
"node": ">= 0.8"
}
},
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/copy-anything": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
@@ -2130,6 +2664,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.307",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
+ "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -2619,6 +3160,13 @@
}
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -2730,6 +3278,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -2846,6 +3404,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gsap": {
+ "version": "3.14.2",
+ "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz",
+ "integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==",
+ "license": "Standard 'no charge' license: https://gsap.com/standard-license."
+ },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -3045,6 +3609,19 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -3073,6 +3650,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3148,6 +3738,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/meshoptimizer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
+ "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -3224,6 +3821,13 @@
"node": ">= 0.4.0"
}
},
+ "node_modules/node-releases": {
+ "version": "2.0.36",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
+ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -3415,7 +4019,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -3594,6 +4197,38 @@
"node": ">=18"
}
},
+ "node_modules/react": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.4"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -3675,6 +4310,12 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -3881,6 +4522,12 @@
"b4a": "^1.6.4"
}
},
+ "node_modules/three": {
+ "version": "0.183.2",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
+ "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -4005,6 +4652,37 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -4269,6 +4947,13 @@
"node": ">=10"
}
},
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 257569b3..736f169b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,13 +12,23 @@
"render-sand": "tsx assets/render-sand.ts"
},
"dependencies": {
+ "@mediapipe/camera_utils": "^0.3.1675466862",
+ "@mediapipe/hands": "^0.4.1675469240",
"axios": "^1.13.6",
+ "gsap": "^3.14.2",
"pinia": "^3.0.4",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "three": "^0.183.2",
"vue": "^3.5.13"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^25.3.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@types/three": "^0.183.1",
+ "@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-vue": "^6.0.4",
"eslint": "^10.0.2",
"eslint-plugin-vue": "^10.8.0",
diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue
index 4709a6be..ad2e8ad7 100644
--- a/frontend/src/components/overlay/CarPage.vue
+++ b/frontend/src/components/overlay/CarPage.vue
@@ -9,8 +9,9 @@
-
-
+
+
+
+
+
@@ -489,11 +510,13 @@ import {
type MyAnswerListItem,
} from '@/lib/api/user'
import { getErrorMessage } from '@/lib/apiClient'
+import { getMemoirs, type Memoir } from '@/lib/api/echo'
import { useBackgroundMusicControls } from '@/composables/useBackgroundMusicLoop'
import avatarFrame from '@/assets/images/frame.png'
import avatarDefault from '@/assets/images/avatar.png'
import boxImg from '@/assets/images/box.png'
+import PearlShellWrapper from '@/components/react/PearlShellWrapper.vue'
const uiStore = useUiStore()
const auth = useAuthStore()
@@ -504,6 +527,15 @@ const showSuitcase = ref(false)
const boxRef = ref
(null)
const modalOrigin = ref>({})
+// ── PearlShell state ──
+const showPearlShell = ref(false)
+const pearlShellFullscreen = ref(false)
+const memoirs = ref([])
+
+const memoirPhotoUrls = computed(() =>
+ memoirs.value.filter(m => m.cover_image_url).map(m => m.cover_image_url!),
+)
+
const visible = computed(() => uiStore.activePanel === 'car')
const wallPhotos = computed(() =>
@@ -616,9 +648,23 @@ function onBackgroundMusicVolumeInput(event: Event) {
// ── Car page methods ──
function close() {
+ showPearlShell.value = false
+ pearlShellFullscreen.value = false
uiStore.closePanel()
}
+function activatePearlShell() {
+ showPearlShell.value = true
+}
+
+function handleRequestFullscreen() {
+ pearlShellFullscreen.value = true
+}
+
+function handleExitFullscreen() {
+ pearlShellFullscreen.value = false
+}
+
function openSuitcase() {
if (boxRef.value) {
const rect = boxRef.value.getBoundingClientRect()
@@ -1052,7 +1098,18 @@ watch(visible, async (isVisible) => {
showSuitcase.value = false
showProfile.value = false
showDetail.value = false
- await Promise.all([fetchPhotos(), fetchProfile()])
+ showPearlShell.value = false
+ pearlShellFullscreen.value = false
+ try {
+ const [, memoirRes] = await Promise.all([
+ fetchPhotos(),
+ getMemoirs(50, 0),
+ fetchProfile(),
+ ])
+ memoirs.value = memoirRes.memoirs
+ } catch {
+ // silent
+ }
}
})
@@ -2215,6 +2272,64 @@ watch(activeTab, (tab) => {
background: rgba(220, 80, 80, 0.25) !important;
}
+/* ── PearlShell ── */
+.pearl-shell-embedded {
+ position: relative;
+ width: 100%;
+ width: 936px;
+ height:544px;
+ margin-top: -34px;
+ margin-left: 215px;
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.pearl-shell-close {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ z-index: 30;
+ width: 28px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.35);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 50%;
+ color: #fff;
+ font-size: 14px;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.pearl-shell-close:hover {
+ background: rgba(0, 0, 0, 0.55);
+}
+
+.pearl-shell-fullscreen {
+ position: fixed;
+ inset: 0;
+ z-index: 150;
+ background: #d48a56;
+}
+
+.pearl-fullscreen-enter-active {
+ transition: opacity 0.4s ease, transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.pearl-fullscreen-leave-active {
+ transition: opacity 0.25s ease, transform 0.25s ease;
+}
+
+.pearl-fullscreen-enter-from,
+.pearl-fullscreen-leave-to {
+ opacity: 0;
+ transform: scale(0.9);
+}
+
/* ── Transitions ── */
.car-page-enter-active {
transition: opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
diff --git a/frontend/src/components/react/PearlShell.tsx b/frontend/src/components/react/PearlShell.tsx
new file mode 100644
index 00000000..3b4b2042
--- /dev/null
+++ b/frontend/src/components/react/PearlShell.tsx
@@ -0,0 +1,1134 @@
+import React, { useEffect, useRef, useState } from 'react';
+import * as THREE from 'three';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
+import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
+import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
+import { Hands, Results } from '@mediapipe/hands';
+import { Camera } from '@mediapipe/camera_utils';
+import gsap from 'gsap';
+
+// --- Props Interface ---
+export interface PearlShellProps {
+ photoUrls: string[];
+ isFullscreen: boolean;
+ onRequestFullscreen?: () => void;
+ onExitFullscreen?: () => void;
+ showDebug?: boolean;
+}
+
+// --- Helper: Create Texture Atlas for Particles ---
+const createAtlasTexture = () => {
+ const canvas = document.createElement('canvas');
+ canvas.width = 512;
+ canvas.height = 512;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return null;
+
+ ctx.clearRect(0, 0, 512, 512);
+ ctx.fillStyle = '#ffffff';
+ ctx.font = '120px Arial';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ ctx.fillText('★', 128, 128);
+ ctx.fillText('●', 384, 128);
+ ctx.fillText('✦', 128, 384);
+ ctx.fillText('✿', 384, 384);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.minFilter = THREE.LinearFilter;
+ texture.magFilter = THREE.LinearFilter;
+ return texture;
+};
+
+// --- Shaders ---
+const shellVertexShader = `
+ attribute vec3 targetPos;
+ attribute float randomOffset;
+ attribute float shapeIndex;
+ attribute float surfaceV;
+
+ uniform float time;
+ uniform float scatterProgress;
+ uniform vec3 cometTailDir;
+
+ varying vec2 vUv;
+ varying float vScatter;
+ varying float vShapeIndex;
+ varying float vSurfaceV;
+ varying float vTrailFade;
+
+ void main() {
+ vUv = uv;
+ vScatter = scatterProgress;
+ vShapeIndex = shapeIndex;
+ vSurfaceV = surfaceV;
+ vTrailFade = 1.0;
+
+ vec3 instanceCenter = (instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz;
+ vec3 vertexOffset = (instanceMatrix * vec4(position, 1.0)).xyz - instanceCenter;
+
+ if (scatterProgress > 0.01) {
+ float t = fract(randomOffset / 6.28318);
+ float tailDist = pow(t, 1.5) * 55.0;
+ float spread = t * 8.0;
+ vec3 spreadDir = normalize(targetPos);
+ vec3 cometPos = cometTailDir * tailDist
+ + spreadDir * spread * (0.6 + 0.4 * sin(time * 0.5 + randomOffset));
+ vTrailFade = 1.0 - t;
+ float sizeScale = mix(2.0, 0.3, t);
+ vec3 scaledOffset = vertexOffset * mix(1.0, sizeScale, scatterProgress);
+ vec3 center = mix(instanceCenter, cometPos, scatterProgress);
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(center + scaledOffset, 1.0);
+ } else {
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(instanceCenter + vertexOffset, 1.0);
+ }
+ }
+`;
+
+const shellFragmentShader = `
+ uniform sampler2D atlas;
+ varying vec2 vUv;
+ varying float vScatter;
+ varying float vShapeIndex;
+ varying float vSurfaceV;
+ varying float vTrailFade;
+
+ void main() {
+ float col = mod(vShapeIndex, 2.0);
+ float row = floor(vShapeIndex / 2.0);
+ vec2 atlasUv = (vUv * 0.5) + vec2(col * 0.5, 0.5 - row * 0.5);
+ vec4 texColor = texture2D(atlas, atlasUv);
+
+ vec3 shellColor = mix(vec3(1.0, 0.95, 0.8), vec3(1.0, 0.7, 0.4), vSurfaceV);
+ float edgeGlow = pow(vSurfaceV, 2.0);
+ shellColor += vec3(1.0, 0.8, 0.5) * edgeGlow * 0.8;
+ float shellAlpha = (0.8 + edgeGlow * 0.4) * texColor.a;
+
+ vec3 headColor = vec3(1.0, 1.0, 0.9);
+ vec3 midColor = vec3(1.0, 0.7, 0.3);
+ vec3 tailColor = vec3(0.8, 0.25, 0.05);
+ vec3 cometColor = mix(tailColor, midColor, smoothstep(0.0, 0.5, vTrailFade));
+ cometColor = mix(cometColor, headColor, smoothstep(0.5, 1.0, vTrailFade));
+ cometColor += vec3(0.5, 0.4, 0.2) * pow(vTrailFade, 3.0);
+ float cometAlpha = pow(vTrailFade, 0.5) * 0.9 * texColor.a;
+
+ vec3 color = mix(shellColor, cometColor, vScatter);
+ float alpha = mix(shellAlpha, cometAlpha, vScatter);
+
+ float thresh = mix(0.1, 0.01, vScatter);
+ if (alpha < thresh) discard;
+
+ gl_FragColor = vec4(color, alpha);
+ }
+`;
+
+// --- Main Component ---
+export default function PearlShell({
+ photoUrls,
+ isFullscreen,
+ onRequestFullscreen,
+ onExitFullscreen,
+ showDebug = false,
+}: PearlShellProps) {
+ const mountRef = useRef(null);
+ const videoRef = useRef(null);
+
+ const [gesture, setGesture] = useState('UNKNOWN');
+ const processedPhotosCount = useRef(0);
+ const [cameraError, setCameraError] = useState(null);
+
+ // Three.js refs
+ const sceneRef = useRef(null);
+ const cameraRef = useRef(null);
+ const rendererRef = useRef(null);
+ const composerRef = useRef(null);
+ const controlsRef = useRef(null);
+
+ const upperShellRef = useRef(null);
+ const lowerShellRef = useRef(null);
+ const pearlRef = useRef(null);
+ const photoGroupRef = useRef(null);
+ const shellGroupRef = useRef(null);
+ const targetShellRotationRef = useRef({ x: 0, y: 0 });
+ const targetShellPositionRef = useRef({ x: 0, y: 0, z: 0 });
+ const handPositionRef = useRef<{ x: number; y: number }>({ x: 0.5, y: 0.5 });
+ const gestureBufferRef = useRef([]);
+ const stableGestureRef = useRef('UNKNOWN');
+ const GESTURE_STABILITY_FRAMES = 5;
+
+ // Debug refs
+ const rawGestureRef = useRef('UNKNOWN');
+ const landmark9Ref = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
+ const [debugInfo, setDebugInfo] = useState({
+ rawGesture: 'UNKNOWN',
+ stableGesture: 'UNKNOWN',
+ landmark9: { x: 0, y: 0 },
+ targetPos: { x: 0, y: 0, z: 0 },
+ shellPos: { x: 0, y: 0, z: 0 },
+ });
+
+ const stateRef = useRef({
+ currentState: 'CLOSED',
+ scatterProgress: 0,
+ shellOpenAngle: 0,
+ bloomIntensity: 0.2,
+ time: 0,
+ });
+
+ const isAnimatingCameraRef = useRef(false);
+ const currentZoomIndexRef = useRef(0);
+ const zoomedPhotoDataRef = useRef<{
+ mesh: THREE.Mesh;
+ originalPosition: THREE.Vector3;
+ originalQuaternion: THREE.Quaternion;
+ } | null>(null);
+
+ // Auto-open ref
+ const hasAutoOpened = useRef(false);
+
+ // transitionTo ref so it can be called from effects
+ const transitionToRef = useRef<(newState: string) => void>(() => {});
+
+ // Initialize Three.js
+ useEffect(() => {
+ if (!mountRef.current) return;
+
+ const width = mountRef.current.clientWidth;
+ const height = mountRef.current.clientHeight;
+
+ const scene = new THREE.Scene();
+ scene.background = new THREE.Color(0xd48a56);
+ scene.fog = new THREE.FogExp2(0xd48a56, 0.015);
+ sceneRef.current = scene;
+
+ const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
+ camera.position.set(0, 0, 28);
+ cameraRef.current = camera;
+
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
+ renderer.setSize(width, height);
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ mountRef.current.appendChild(renderer.domElement);
+ rendererRef.current = renderer;
+
+ const controls = new OrbitControls(camera, renderer.domElement);
+ controls.enableDamping = true;
+ controls.dampingFactor = 0.05;
+ controls.target.set(0, 0, 0);
+ controlsRef.current = controls;
+
+ // Post-processing
+ const renderScene = new RenderPass(scene, camera);
+ const bloomPass = new UnrealBloomPass(new THREE.Vector2(width, height), 0.4, 0.5, 0.85);
+ bloomPass.threshold = 0.5;
+ bloomPass.strength = 0.6;
+ bloomPass.radius = 0.7;
+
+ const composer = new EffectComposer(renderer);
+ composer.addPass(renderScene);
+ composer.addPass(bloomPass);
+ composerRef.current = composer;
+
+ // Lights
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.8);
+ scene.add(ambientLight);
+ const dirLight = new THREE.DirectionalLight(0xffeedd, 1);
+ dirLight.position.set(5, 10, 5);
+ scene.add(dirLight);
+
+ // Shell Group
+ const shellGroup = new THREE.Group();
+ scene.add(shellGroup);
+ shellGroupRef.current = shellGroup;
+
+ // Pearl
+ const pearlGeo = new THREE.SphereGeometry(1.5, 64, 64);
+ const pearlMat = new THREE.MeshPhysicalMaterial({
+ color: 0xfff5ee,
+ emissive: 0x22110a,
+ roughness: 0.1,
+ metalness: 0.1,
+ clearcoat: 1.0,
+ clearcoatRoughness: 0.1,
+ iridescence: 1.0,
+ iridescenceIOR: 1.5,
+ iridescenceThicknessRange: [100, 400],
+ transparent: true,
+ opacity: 1,
+ });
+ const pearl = new THREE.Mesh(pearlGeo, pearlMat);
+ pearl.position.set(0, -2, 0);
+ shellGroup.add(pearl);
+ pearlRef.current = pearl;
+
+ const pearlLight = new THREE.PointLight(0xffaa55, 2, 20);
+ pearlLight.position.copy(pearl.position);
+ shellGroup.add(pearlLight);
+
+ const pearlLightRef = { current: pearlLight };
+ const pearlMatRef = { current: pearlMat };
+
+ // Shells
+ const particleCount = 8000;
+ const scaleGeo = new THREE.PlaneGeometry(0.15, 0.15);
+
+ const atlasTex = createAtlasTexture();
+
+ const shellMat = new THREE.ShaderMaterial({
+ vertexShader: shellVertexShader,
+ fragmentShader: shellFragmentShader,
+ uniforms: {
+ time: { value: 0 },
+ scatterProgress: { value: 0 },
+ atlas: { value: atlasTex },
+ cometTailDir: { value: new THREE.Vector3(0, -1, 0) },
+ },
+ transparent: true,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ blending: THREE.NormalBlending,
+ });
+
+ const createShellHalf = (isUpper: boolean) => {
+ const instancedMesh = new THREE.InstancedMesh(scaleGeo, shellMat, particleCount);
+ const dummy = new THREE.Object3D();
+
+ const targetPosArray = new Float32Array(particleCount * 3);
+ const randomOffsetArray = new Float32Array(particleCount);
+ const shapeIndexArray = new Float32Array(particleCount);
+ const surfaceVArray = new Float32Array(particleCount);
+
+ const numRibs = 13;
+ const thetaMax = Math.PI * 0.4;
+
+ for (let i = 0; i < particleCount; i++) {
+ let u = Math.random();
+ let v = Math.random();
+
+ const type = Math.random();
+ if (type < 0.5) {
+ const ribU = u * (numRibs - 1);
+ u = (Math.floor(ribU + 0.5) + (Math.random() - 0.5) * 0.15) / (numRibs - 1);
+ u = Math.max(0, Math.min(1, u));
+ } else if (type < 0.7) {
+ v = 1.0 - Math.pow(Math.random(), 4.0);
+ } else if (type < 0.8) {
+ v = Math.pow(Math.random(), 4.0);
+ }
+
+ const theta = -thetaMax + u * (2 * thetaMax);
+ const L = 14 + 2 * Math.cos(theta);
+
+ const exactRibU = u * (numRibs - 1);
+ const exactRibFraction = exactRibU - Math.floor(exactRibU);
+ const bulge = Math.sin(exactRibFraction * Math.PI) * 1.2 * v;
+
+ const R = v * L + bulge;
+
+ const taper = Math.pow(Math.sin(u * Math.PI), 0.5);
+ let z = Math.sin(v * Math.PI) * taper * 3.5;
+
+ let x = R * Math.sin(theta);
+ let y = -6 + R * Math.cos(theta);
+
+ x += (Math.random() - 0.5) * 0.3;
+ y += (Math.random() - 0.5) * 0.3;
+ z += (Math.random() - 0.5) * 0.3;
+
+ if (!isUpper) {
+ z = -z;
+ }
+
+ dummy.position.set(x, y, z);
+ dummy.lookAt(x * 2, y * 2, z * 2 + (isUpper ? 10 : -10));
+ dummy.rotateZ(Math.random() * Math.PI * 2);
+ dummy.updateMatrix();
+ instancedMesh.setMatrixAt(i, dummy.matrix);
+
+ targetPosArray[i * 3] = (Math.random() - 0.5) * 60;
+ targetPosArray[i * 3 + 1] = (Math.random() - 0.5) * 60;
+ targetPosArray[i * 3 + 2] = (Math.random() - 0.5) * 60;
+
+ randomOffsetArray[i] = Math.random() * Math.PI * 2;
+ shapeIndexArray[i] = Math.floor(Math.random() * 4);
+ surfaceVArray[i] = v;
+ }
+
+ instancedMesh.geometry.setAttribute('targetPos', new THREE.InstancedBufferAttribute(targetPosArray, 3));
+ instancedMesh.geometry.setAttribute('randomOffset', new THREE.InstancedBufferAttribute(randomOffsetArray, 1));
+ instancedMesh.geometry.setAttribute('shapeIndex', new THREE.InstancedBufferAttribute(shapeIndexArray, 1));
+ instancedMesh.geometry.setAttribute('surfaceV', new THREE.InstancedBufferAttribute(surfaceVArray, 1));
+
+ instancedMesh.position.set(0, 0, 0);
+ for (let i = 0; i < particleCount; i++) {
+ instancedMesh.getMatrixAt(i, dummy.matrix);
+ dummy.matrix.decompose(dummy.position, dummy.quaternion, dummy.scale);
+ dummy.position.y -= -6;
+ dummy.updateMatrix();
+ instancedMesh.setMatrixAt(i, dummy.matrix);
+ }
+ instancedMesh.position.y = -6;
+
+ return instancedMesh;
+ };
+
+ const upperShell = createShellHalf(true);
+ const lowerShell = createShellHalf(false);
+
+ shellGroup.add(upperShell);
+ shellGroup.add(lowerShell);
+ upperShellRef.current = upperShell;
+ lowerShellRef.current = lowerShell;
+
+ // Photo Group
+ const photoGroup = new THREE.Group();
+ shellGroup.add(photoGroup);
+ photoGroupRef.current = photoGroup;
+
+ // Animation Loop
+ const clock = new THREE.Clock();
+ let animFrameId: number;
+ const prevShellPos = new THREE.Vector3();
+
+ const animate = () => {
+ animFrameId = requestAnimationFrame(animate);
+
+ const time = clock.getElapsedTime();
+ stateRef.current.time = time;
+
+ if (!isAnimatingCameraRef.current) {
+ controls.update();
+ }
+
+ if (pearlLightRef.current && pearlMatRef.current) {
+ const pulse = stateRef.current.bloomIntensity;
+ pearlLightRef.current.intensity = 2 + pulse * 2;
+ pearlMatRef.current.emissiveIntensity = 1 + pulse;
+ }
+
+ shellMat.uniforms.time.value = time;
+ shellMat.uniforms.scatterProgress.value = stateRef.current.scatterProgress;
+
+ if (shellGroupRef.current) {
+ shellGroupRef.current.rotation.x += (targetShellRotationRef.current.x - shellGroupRef.current.rotation.x) * 0.1;
+ shellGroupRef.current.rotation.y += (targetShellRotationRef.current.y - shellGroupRef.current.rotation.y) * 0.1;
+ }
+
+ if (shellGroupRef.current) {
+ shellGroupRef.current.position.x += (targetShellPositionRef.current.x - shellGroupRef.current.position.x) * 0.2;
+ shellGroupRef.current.position.y += (targetShellPositionRef.current.y - shellGroupRef.current.position.y) * 0.2;
+ shellGroupRef.current.position.z += (targetShellPositionRef.current.z - shellGroupRef.current.position.z) * 0.2;
+ }
+
+ if (upperShellRef.current) {
+ upperShellRef.current.rotation.x = stateRef.current.shellOpenAngle;
+ }
+ if (lowerShellRef.current) {
+ lowerShellRef.current.rotation.x = -stateRef.current.shellOpenAngle;
+ }
+
+ // Comet tail direction
+ if (shellGroupRef.current && stateRef.current.scatterProgress > 0.01) {
+ const sp = shellGroupRef.current.position;
+ const dx = sp.x - prevShellPos.x;
+ const dy = sp.y - prevShellPos.y;
+ const spd = Math.sqrt(dx * dx + dy * dy);
+
+ if (spd > 0.05) {
+ const tailVal = shellMat.uniforms.cometTailDir.value;
+ tailVal.x += ((-dx / spd) - tailVal.x) * 0.08;
+ tailVal.y += ((-dy / spd) - tailVal.y) * 0.08;
+ tailVal.normalize();
+ }
+
+ prevShellPos.set(sp.x, sp.y, sp.z);
+ }
+
+ // Animate Photos
+ if (photoGroupRef.current) {
+ const zData = zoomedPhotoDataRef.current;
+ if (stateRef.current.currentState === 'PHOTO_ZOOM' && zData) {
+ zData.mesh.position.y += Math.sin(time * 1.5) * 0.003;
+ zData.mesh.rotation.y = Math.sin(time * 0.8) * 0.03;
+ }
+ }
+
+ // Bloom pass
+ camera.layers.set(0);
+ composer.render();
+
+ // Photos without bloom
+ if (photoGroupRef.current && photoGroupRef.current.children.length > 0) {
+ camera.layers.set(1);
+ renderer.autoClear = false;
+ renderer.clearDepth();
+ const bg = scene.background;
+ scene.background = null;
+ renderer.render(scene, camera);
+ scene.background = bg;
+ renderer.autoClear = true;
+ }
+
+ camera.layers.enableAll();
+ };
+
+ animate();
+
+ // ResizeObserver instead of window resize
+ const handleResize = () => {
+ if (!mountRef.current) return;
+ const w = mountRef.current.clientWidth;
+ const h = mountRef.current.clientHeight;
+ camera.aspect = w / h;
+ camera.updateProjectionMatrix();
+ renderer.setSize(w, h);
+ composer.setSize(w, h);
+ };
+
+ const resizeObserver = new ResizeObserver(handleResize);
+ resizeObserver.observe(mountRef.current);
+
+ return () => {
+ cancelAnimationFrame(animFrameId);
+ controls.dispose();
+ resizeObserver.disconnect();
+ mountRef.current?.removeChild(renderer.domElement);
+ renderer.dispose();
+ };
+ }, []);
+
+ // Update Photos from props
+ useEffect(() => {
+ if (!photoGroupRef.current || photoUrls.length === 0) return;
+
+ const group = photoGroupRef.current;
+ const textureLoader = new THREE.TextureLoader();
+ textureLoader.setCrossOrigin('anonymous');
+
+ const newPhotos = photoUrls.slice(processedPhotosCount.current);
+ if (newPhotos.length === 0) return;
+
+ newPhotos.forEach((photoUrl) => {
+ textureLoader.load(
+ photoUrl,
+ (texture) => {
+ texture.colorSpace = THREE.SRGBColorSpace;
+ const aspect = texture.image.width / texture.image.height;
+ const geo = new THREE.PlaneGeometry(2 * aspect, 2);
+ const mat = new THREE.MeshBasicMaterial({
+ map: texture,
+ side: THREE.DoubleSide,
+ transparent: true,
+ opacity: 0.0,
+ depthWrite: false,
+ fog: false,
+ });
+ const mesh = new THREE.Mesh(geo, mat);
+ mesh.layers.set(1);
+
+ const angle = Math.random() * Math.PI * 2;
+ const radius = 6 + Math.random() * 4;
+ const height = (Math.random() - 0.5) * 8;
+
+ mesh.position.set(
+ Math.cos(angle) * radius,
+ height,
+ Math.sin(angle) * radius,
+ );
+ mesh.lookAt(0, 0, 0);
+
+ group.add(mesh);
+
+ const currentState = stateRef.current.currentState;
+ if (currentState === 'OPEN') {
+ mat.opacity = 1;
+ } else if (currentState === 'PHOTO_ZOOM') {
+ mat.opacity = 0.3;
+ }
+ },
+ undefined,
+ (err) => {
+ console.error('Failed to load photo texture:', err);
+ },
+ );
+ });
+
+ processedPhotosCount.current = photoUrls.length;
+ }, [photoUrls]);
+
+ // Helper: put back zoomed photo
+ const putBackZoomedPhoto = () => {
+ const data = zoomedPhotoDataRef.current;
+ if (!data) return;
+ const { mesh, originalPosition, originalQuaternion } = data;
+ gsap.to(mesh.position, {
+ x: originalPosition.x,
+ y: originalPosition.y,
+ z: originalPosition.z,
+ duration: 0.5,
+ ease: 'power2.inOut',
+ });
+ gsap.to(mesh.quaternion, {
+ x: originalQuaternion.x,
+ y: originalQuaternion.y,
+ z: originalQuaternion.z,
+ w: originalQuaternion.w,
+ duration: 0.5,
+ ease: 'power2.inOut',
+ });
+ gsap.to(mesh.scale, { x: 1, y: 1, z: 1, duration: 0.5, ease: 'power2.inOut' });
+ zoomedPhotoDataRef.current = null;
+ };
+
+ // State Transitions
+ const transitionTo = (newState: string) => {
+ const state = stateRef.current;
+ if (state.currentState === newState) return;
+
+ if (state.currentState === 'PHOTO_ZOOM' && newState !== 'PHOTO_ZOOM') {
+ putBackZoomedPhoto();
+ if (photoGroupRef.current) {
+ photoGroupRef.current.children.forEach((child) => {
+ gsap.to(child.scale, { x: 1, y: 1, z: 1, duration: 0.5 });
+ });
+ }
+ }
+
+ if (newState === 'PHOTO_ZOOM' && (!photoGroupRef.current || photoGroupRef.current.children.length === 0)) {
+ return;
+ }
+
+ const prevState = state.currentState;
+ state.currentState = newState;
+
+ if (newState === 'CLOSED') {
+ const photosVisible = prevState === 'OPEN' || prevState === 'PHOTO_ZOOM';
+ const fromScattered = prevState === 'SCATTERED';
+ const shellDelay = photosVisible ? 1.5 : 0;
+ const pearlDelay = fromScattered ? 2 : shellDelay;
+
+ gsap.to(state, { shellOpenAngle: 0, duration: 1.5, delay: shellDelay, ease: 'power2.inOut' });
+ gsap.to(state, { scatterProgress: 0, duration: 2, ease: 'power2.inOut' });
+ gsap.to(state, { bloomIntensity: 0.2, duration: 1, delay: shellDelay });
+
+ if (pearlRef.current) {
+ gsap.killTweensOf(pearlRef.current.material, 'opacity');
+ pearlRef.current.visible = true;
+ (pearlRef.current.material as THREE.MeshPhysicalMaterial).opacity = 0;
+ gsap.to(pearlRef.current.material, { opacity: 1, duration: 1.5, delay: pearlDelay });
+ }
+
+ if (photoGroupRef.current) {
+ photoGroupRef.current.children.forEach((child) => {
+ const mat = (child as THREE.Mesh).material;
+ gsap.killTweensOf(mat, 'opacity');
+ gsap.to(mat, { opacity: 0, duration: 1 });
+ });
+ }
+
+ if (cameraRef.current && controlsRef.current) {
+ isAnimatingCameraRef.current = true;
+ gsap.to(cameraRef.current.position, { x: 0, y: 0, z: 28, duration: 2, delay: shellDelay });
+ gsap.to(controlsRef.current.target, {
+ x: 0, y: 0, z: 0, duration: 2, delay: shellDelay,
+ onComplete: () => { isAnimatingCameraRef.current = false; },
+ });
+ }
+ } else if (newState === 'OPEN') {
+ gsap.to(state, { shellOpenAngle: (Math.PI / 180) * 60, duration: 2, ease: 'power2.inOut' });
+ gsap.to(state, { scatterProgress: 0, duration: 2, ease: 'power2.inOut' });
+ gsap.to(state, { bloomIntensity: 0.8, duration: 2 });
+
+ if (pearlRef.current) {
+ gsap.killTweensOf(pearlRef.current.material, 'opacity');
+ gsap.to(pearlRef.current.material, {
+ opacity: 0, duration: 1.5,
+ onComplete: () => {
+ if (pearlRef.current) pearlRef.current.visible = false;
+ },
+ });
+ }
+
+ if (photoGroupRef.current) {
+ photoGroupRef.current.children.forEach((child) => {
+ gsap.to((child as THREE.Mesh).material, { opacity: 1, duration: 2, delay: 1.5 });
+ });
+ }
+ } else if (newState === 'SCATTERED') {
+ gsap.to(state, { shellOpenAngle: 0, duration: 1, ease: 'power2.inOut' });
+ gsap.to(state, { scatterProgress: 1, duration: 3, ease: 'power2.inOut' });
+ gsap.to(state, { bloomIntensity: 0.3, duration: 2 });
+
+ if (pearlRef.current) {
+ gsap.killTweensOf(pearlRef.current.material, 'opacity');
+ (pearlRef.current.material as THREE.MeshPhysicalMaterial).opacity = 0;
+ pearlRef.current.visible = false;
+ }
+
+ if (photoGroupRef.current) {
+ photoGroupRef.current.children.forEach((child) => {
+ gsap.to((child as THREE.Mesh).material, { opacity: 0, duration: 1.5 });
+ });
+ }
+
+ if (cameraRef.current && controlsRef.current) {
+ isAnimatingCameraRef.current = true;
+ gsap.to(cameraRef.current.position, { x: 0, y: 5, z: 35, duration: 2 });
+ gsap.to(controlsRef.current.target, {
+ x: 0, y: 0, z: 0, duration: 2,
+ onComplete: () => { isAnimatingCameraRef.current = false; },
+ });
+ }
+ } else if (newState === 'PHOTO_ZOOM') {
+ targetShellPositionRef.current = { x: 0, y: 0, z: 0 };
+ targetShellRotationRef.current = { x: 0, y: 0 };
+
+ gsap.to(state, { shellOpenAngle: (Math.PI / 180) * 60, duration: 1, ease: 'power2.inOut' });
+ gsap.to(state, { scatterProgress: 0, duration: 1, ease: 'power2.inOut' });
+
+ const children = photoGroupRef.current!.children;
+ const count = children.length;
+ if (count === 0) return;
+
+ putBackZoomedPhoto();
+
+ const index = currentZoomIndexRef.current % count;
+ currentZoomIndexRef.current = (index + 1) % count;
+ const photo = children[index] as THREE.Mesh;
+
+ const originalPosition = photo.position.clone();
+ const originalQuaternion = photo.quaternion.clone();
+ zoomedPhotoDataRef.current = { mesh: photo, originalPosition, originalQuaternion };
+
+ children.forEach((child) => {
+ gsap.to((child as THREE.Mesh).material, { opacity: 0.3, duration: 0.5 });
+ });
+
+ gsap.to(photo.position, {
+ x: 0, y: 0, z: 18,
+ duration: 0.8, ease: 'back.out(1.4)',
+ });
+ gsap.to(photo.quaternion, {
+ x: 0, y: 0, z: 0, w: 1,
+ duration: 0.8, ease: 'power2.out',
+ });
+ gsap.to(photo.scale, { x: 2.5, y: 2.5, z: 2.5, duration: 0.8, ease: 'back.out(1.2)' });
+ gsap.to(photo.material, { opacity: 1, duration: 0.5 });
+ }
+ };
+
+ // Keep transitionToRef in sync
+ transitionToRef.current = transitionTo;
+
+ // Auto-open animation
+ useEffect(() => {
+ if (photoUrls.length > 0 && !hasAutoOpened.current) {
+ hasAutoOpened.current = true;
+ setTimeout(() => transitionToRef.current('OPEN'), 800);
+ }
+ }, [photoUrls]);
+
+ // Initialize MediaPipe Hands
+ useEffect(() => {
+ if (!videoRef.current) return;
+
+ let cancelled = false;
+ let handsInstance: Hands | null = null;
+ let cameraInstance: Camera | null = null;
+
+ const initMediaPipe = async () => {
+ const hands = new Hands({
+ locateFile: (file) => {
+ return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`;
+ },
+ });
+
+ if (cancelled) { hands.close(); return; }
+ handsInstance = hands;
+
+ hands.setOptions({
+ maxNumHands: 1,
+ modelComplexity: 1,
+ minDetectionConfidence: 0.5,
+ minTrackingConfidence: 0.5,
+ });
+
+ hands.onResults((results: Results) => {
+ if (cancelled) return;
+ if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
+ const landmarks = results.multiHandLandmarks[0];
+
+ const getDist2D = (p1: { x: number; y: number }, p2: { x: number; y: number }) => {
+ return Math.hypot(p1.x - p2.x, p1.y - p2.y);
+ };
+ const palmSize = getDist2D(landmarks[0], landmarks[9]);
+
+ const isExtended = (tipIdx: number, pipIdx: number) => {
+ return getDist2D(landmarks[tipIdx], landmarks[0]) > getDist2D(landmarks[pipIdx], landmarks[0]);
+ };
+
+ const indexExt = isExtended(8, 6);
+ const middleExt = isExtended(12, 10);
+ const ringExt = isExtended(16, 14);
+ const pinkyExt = isExtended(20, 18);
+
+ const allExt = indexExt && middleExt && ringExt && pinkyExt;
+ const noneExt = !indexExt && !middleExt && !ringExt && !pinkyExt;
+ const peaceExt = indexExt && middleExt && !ringExt && !pinkyExt;
+
+ let detectedGesture = 'UNKNOWN';
+
+ const thumbFingerDists = [
+ getDist2D(landmarks[4], landmarks[8]),
+ getDist2D(landmarks[4], landmarks[12]),
+ getDist2D(landmarks[4], landmarks[16]),
+ getDist2D(landmarks[4], landmarks[20]),
+ ];
+ const minThumbFingerDist = Math.min(...thumbFingerDists) / palmSize;
+
+ if (noneExt) {
+ detectedGesture = 'FIST';
+ } else if (peaceExt) {
+ detectedGesture = 'PEACE';
+ } else if (minThumbFingerDist < 0.3) {
+ detectedGesture = 'OK';
+ } else if (allExt) {
+ detectedGesture = 'OPEN';
+ }
+
+ rawGestureRef.current = detectedGesture;
+ landmark9Ref.current = { x: landmarks[9].x, y: landmarks[9].y };
+
+ const buffer = gestureBufferRef.current;
+ buffer.push(detectedGesture);
+ if (buffer.length > GESTURE_STABILITY_FRAMES) buffer.shift();
+
+ if (buffer.length === GESTURE_STABILITY_FRAMES && buffer.every((g) => g === detectedGesture)) {
+ if (stableGestureRef.current !== detectedGesture) {
+ stableGestureRef.current = detectedGesture;
+ setGesture(detectedGesture);
+ }
+ }
+
+ if (stateRef.current.currentState === 'PHOTO_ZOOM') {
+ targetShellPositionRef.current = { x: 0, y: 0, z: 0 };
+ targetShellRotationRef.current = { x: 0, y: 0 };
+ } else {
+ const posX = Math.max(-15, Math.min(15, -(landmarks[9].x - 0.5) * 30));
+ const posY = Math.max(-10, Math.min(10, -(landmarks[9].y - 0.5) * 20));
+ targetShellPositionRef.current = { x: posX, y: posY, z: 0 };
+
+ const targetY = (landmarks[9].x - 0.5) * Math.PI * 2;
+ const targetX = (landmarks[9].y - 0.5) * Math.PI;
+ targetShellRotationRef.current = { x: targetX, y: targetY };
+ }
+
+ if (detectedGesture === 'OK') {
+ handPositionRef.current = { x: landmarks[9].x, y: landmarks[9].y };
+ }
+
+ if (stateRef.current.currentState === 'SCATTERED' && detectedGesture !== 'OK' && detectedGesture !== 'FIST' && detectedGesture !== 'PEACE') {
+ if (cameraRef.current && controlsRef.current) {
+ const x = (landmarks[9].x - 0.5) * 2;
+ const y = -(landmarks[9].y - 0.5) * 2;
+ const targetX = x * 20;
+ const targetY = Math.max(2, y * 20 + 10);
+ gsap.to(cameraRef.current.position, {
+ x: targetX,
+ y: targetY,
+ duration: 0.5,
+ ease: 'power1.out',
+ });
+ }
+ }
+ }
+ });
+
+ if (cancelled || !videoRef.current) { hands.close(); return; }
+
+ const cam = new Camera(videoRef.current, {
+ onFrame: async () => {
+ if (!cancelled && videoRef.current) {
+ await hands.send({ image: videoRef.current });
+ }
+ },
+ width: 640,
+ height: 480,
+ });
+ cameraInstance = cam;
+
+ const originalAlert = window.alert;
+ window.alert = () => {};
+
+ try {
+ await cam.start();
+ } catch (err: unknown) {
+ console.error('Camera start failed:', err);
+ if (!cancelled) {
+ const message = err instanceof Error ? err.message : '';
+ setCameraError(message || 'Failed to access camera. Please ensure permissions are granted.');
+ }
+ } finally {
+ window.alert = originalAlert;
+ }
+
+ if (cancelled) {
+ cam.stop();
+ hands.close();
+ }
+ };
+
+ initMediaPipe();
+
+ return () => {
+ cancelled = true;
+ if (cameraInstance) cameraInstance.stop();
+ if (handsInstance) handsInstance.close();
+ gestureBufferRef.current = [];
+ };
+ }, []);
+
+ // Gesture → state transitions
+ useEffect(() => {
+ const currentState = stateRef.current.currentState;
+
+ if (gesture === 'FIST') {
+ transitionTo('CLOSED');
+ } else if (currentState === 'PHOTO_ZOOM') {
+ if (gesture === 'OPEN') {
+ transitionTo('OPEN');
+ }
+ return;
+ } else if (gesture === 'PEACE') {
+ transitionTo('OPEN');
+ } else if (gesture === 'OPEN') {
+ if (currentState !== 'OPEN') {
+ transitionTo('SCATTERED');
+ }
+ } else if (gesture === 'OK') {
+ if (currentState === 'OPEN') {
+ transitionTo('PHOTO_ZOOM');
+ }
+ }
+ }, [gesture]);
+
+ // Debug info update
+ useEffect(() => {
+ if (!showDebug) return;
+ const interval = setInterval(() => {
+ const shellGroup = shellGroupRef.current;
+ setDebugInfo({
+ rawGesture: rawGestureRef.current,
+ stableGesture: stableGestureRef.current,
+ landmark9: { ...landmark9Ref.current },
+ targetPos: { ...targetShellPositionRef.current },
+ shellPos: shellGroup
+ ? { x: shellGroup.position.x, y: shellGroup.position.y, z: shellGroup.position.z }
+ : { x: 0, y: 0, z: 0 },
+ });
+ }, 200);
+ return () => clearInterval(interval);
+ }, [showDebug]);
+
+ return (
+
+ {/* Three.js mount point */}
+
+
+ {/* Fullscreen toggle button */}
+
+ {isFullscreen ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Gesture hints */}
+
+
GESTURE CONTROLS
+
✊ FIST Close Shell
+
✌️ PEACE Open Shell
+
🖐️ OPEN Scatter
+
👌 OK Zoom Photo
+
+
+ {/* Debug panel */}
+ {showDebug && (
+
+
Raw: {debugInfo.rawGesture}
+
Stable: {debugInfo.stableGesture}
+
State: {stateRef.current.currentState}
+
Lm9: ({debugInfo.landmark9.x.toFixed(3)}, {debugInfo.landmark9.y.toFixed(3)})
+
Target: ({debugInfo.targetPos.x.toFixed(1)}, {debugInfo.targetPos.y.toFixed(1)})
+
Shell: ({debugInfo.shellPos.x.toFixed(1)}, {debugInfo.shellPos.y.toFixed(1)})
+
+ )}
+
+ {/* Camera error */}
+ {cameraError && (
+
+
Camera Access Denied
+
{cameraError}
+
+
+ )}
+
+
+ {/* Camera preview */}
+
+
+ );
+}
diff --git a/frontend/src/components/react/PearlShellWrapper.vue b/frontend/src/components/react/PearlShellWrapper.vue
new file mode 100644
index 00000000..473e7392
--- /dev/null
+++ b/frontend/src/components/react/PearlShellWrapper.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
index 14a4a67b..32bf89c4 100644
--- a/frontend/tsconfig.node.json
+++ b/frontend/tsconfig.node.json
@@ -4,7 +4,7 @@
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
@@ -20,6 +20,7 @@
"vite.config.ts",
"env.d.ts",
"src/**/*.ts",
- "src/**/*.vue"
+ "src/**/*.vue",
+ "src/**/*.tsx"
]
}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 2c84cd82..1fbed955 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,9 +1,13 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
+import react from '@vitejs/plugin-react'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
- plugins: [vue()],
+ plugins: [
+ vue(),
+ react({ include: /\.(tsx|jsx)$/ }),
+ ],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),