Spaces:
Running
Running
ready player implementation
Browse files- READY_PLAYER_ME_IMPLEMENTATION_SUMMARY.md +154 -0
- READY_PLAYER_ME_INTEGRATION_PLAN.md +295 -0
- package-lock.json +17 -0
- package.json +1 -0
- src/entity/User.ts +6 -0
- src/entity/WardrobeItem.ts +6 -0
- src/index.ts +5 -0
- src/migrations/1700000000004-AddModel3DUrl.ts +18 -0
- src/migrations/1700000000005-AddReadyPlayerMeFields.ts +38 -0
- src/routes/auth.ts +15 -0
- src/routes/avatar.ts +406 -0
- src/routes/profile.ts +37 -43
- src/routes/suggest.ts +84 -3
- src/routes/upload.ts +77 -1
- src/utils/assetTypeMapper.ts +96 -0
- src/utils/readyPlayerMe.ts +396 -0
- src/utils/tencent3D.ts +165 -0
- stylegptUI +1 -1
- tits.jpeg +0 -0
READY_PLAYER_ME_IMPLEMENTATION_SUMMARY.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ready Player Me Integration - Implementation Summary
|
| 2 |
+
|
| 3 |
+
## ✅ Implementation Complete
|
| 4 |
+
|
| 5 |
+
All phases of the Ready Player Me integration have been implemented:
|
| 6 |
+
|
| 7 |
+
### Phase 1: User Avatar Management ✅
|
| 8 |
+
- **Backend:**
|
| 9 |
+
- User registration creates Ready Player Me guest user automatically
|
| 10 |
+
- Avatar creation endpoint (`/api/avatar/create`)
|
| 11 |
+
- Avatar GLB retrieval endpoint (`/api/avatar/glb`)
|
| 12 |
+
- Avatar 2D render endpoint (`/api/avatar/render`)
|
| 13 |
+
- Avatar metadata endpoint (`/api/avatar/metadata`)
|
| 14 |
+
|
| 15 |
+
- **Frontend:**
|
| 16 |
+
- Avatar creation UI in Profile page with iframe integration
|
| 17 |
+
- Listens for `v1.avatar.exported` event
|
| 18 |
+
- Stores avatar ID automatically
|
| 19 |
+
|
| 20 |
+
### Phase 2: Asset Conversion ✅
|
| 21 |
+
- **Backend:**
|
| 22 |
+
- Upload route automatically converts wardrobe items to Ready Player Me assets
|
| 23 |
+
- Category mapping to asset types (shirts→top, pants→bottom, etc.)
|
| 24 |
+
- Uploads GLB models to Ready Player Me temporary storage
|
| 25 |
+
- Creates assets with proper configuration
|
| 26 |
+
- Stores asset IDs in database
|
| 27 |
+
|
| 28 |
+
### Phase 3: Outfit Visualization ✅
|
| 29 |
+
- **Backend:**
|
| 30 |
+
- Suggest route equips assets to user's avatar
|
| 31 |
+
- Returns avatar GLB URL with equipped outfit
|
| 32 |
+
- Handles multiple asset equipping
|
| 33 |
+
|
| 34 |
+
- **Frontend:**
|
| 35 |
+
- Displays avatar with outfit in Chat page
|
| 36 |
+
- Shows selected items grid
|
| 37 |
+
- Interactive 3D preview
|
| 38 |
+
|
| 39 |
+
## 📁 Files Created/Modified
|
| 40 |
+
|
| 41 |
+
### Backend Files:
|
| 42 |
+
1. **`src/utils/readyPlayerMe.ts`** - Ready Player Me API client
|
| 43 |
+
2. **`src/utils/assetTypeMapper.ts`** - Category to asset type mapping
|
| 44 |
+
3. **`src/routes/avatar.ts`** - Avatar management endpoints
|
| 45 |
+
4. **`src/entity/User.ts`** - Added `readyPlayerMeUserId`, `readyPlayerMeAvatarId`
|
| 46 |
+
5. **`src/entity/WardrobeItem.ts`** - Added `readyPlayerMeAssetId`
|
| 47 |
+
6. **`src/migrations/1700000000005-AddReadyPlayerMeFields.ts`** - Database migration
|
| 48 |
+
7. **`src/routes/auth.ts`** - Updated to create guest user on registration
|
| 49 |
+
8. **`src/routes/upload.ts`** - Updated to create assets after 3D generation
|
| 50 |
+
9. **`src/routes/suggest.ts`** - Updated to equip assets and return avatar URL
|
| 51 |
+
10. **`src/index.ts`** - Added avatar route
|
| 52 |
+
|
| 53 |
+
### Frontend Files:
|
| 54 |
+
1. **`stylegptUI/src/utils/api.js`** - Added `avatarAPI`
|
| 55 |
+
2. **`stylegptUI/src/pages/Profile.jsx`** - Added avatar creator iframe
|
| 56 |
+
3. **`stylegptUI/src/pages/Profile.scss`** - Added avatar creator modal styles
|
| 57 |
+
4. **`stylegptUI/src/pages/Chat.jsx`** - Added avatar with outfit display
|
| 58 |
+
5. **`stylegptUI/src/pages/Chat.scss`** - Added avatar preview and selected items styles
|
| 59 |
+
|
| 60 |
+
## 🔧 Required Environment Variables
|
| 61 |
+
|
| 62 |
+
Add these to your `.env` file:
|
| 63 |
+
|
| 64 |
+
```env
|
| 65 |
+
# Ready Player Me API Key (already set)
|
| 66 |
+
READY_API_KEY=sk_live_mRM0Hj9Ji2bUGkol4E55qv3Zfqd1J7782Yvo
|
| 67 |
+
|
| 68 |
+
# Ready Player Me Application ID (get from Studio)
|
| 69 |
+
READY_PLAYER_ME_APPLICATION_ID=your_application_id_here
|
| 70 |
+
|
| 71 |
+
# Ready Player Me Organization ID (get from Studio)
|
| 72 |
+
READY_PLAYER_ME_ORGANIZATION_ID=your_organization_id_here
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
For frontend (add to `stylegptUI/.env` or `stylegptUI/.env.local`):
|
| 76 |
+
|
| 77 |
+
```env
|
| 78 |
+
# Ready Player Me Subdomain (your partner name)
|
| 79 |
+
VITE_READY_PLAYER_ME_SUBDOMAIN=your_subdomain_here
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
## 🚀 How It Works
|
| 83 |
+
|
| 84 |
+
### 1. User Registration Flow:
|
| 85 |
+
```
|
| 86 |
+
User Signs Up → Backend creates Ready Player Me guest user → Stores user ID in database
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
### 2. Avatar Creation Flow:
|
| 90 |
+
```
|
| 91 |
+
User clicks "Create Avatar" → Opens Ready Player Me iframe →
|
| 92 |
+
User creates avatar → Event fired → Backend stores avatar ID
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
### 3. Wardrobe Upload Flow:
|
| 96 |
+
```
|
| 97 |
+
User uploads item → Background removal → FashionClip classification →
|
| 98 |
+
3D model generation → Upload GLB to Ready Player Me →
|
| 99 |
+
Create asset → Store asset ID in database
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
### 4. Outfit Suggestion Flow:
|
| 103 |
+
```
|
| 104 |
+
User asks for outfit → AI suggests items → Backend equips assets to avatar →
|
| 105 |
+
Get avatar GLB with outfit → Display in frontend
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
## 🎯 Features Implemented
|
| 109 |
+
|
| 110 |
+
1. ✅ **Guest User Creation** - Automatic on signup
|
| 111 |
+
2. ✅ **Avatar Creation** - Iframe integration with event listening
|
| 112 |
+
3. ✅ **Asset Conversion** - Automatic conversion of wardrobe items
|
| 113 |
+
4. ✅ **Asset Equipping** - Equip multiple assets to avatar
|
| 114 |
+
5. ✅ **3D Outfit Visualization** - Display avatar wearing suggested outfit
|
| 115 |
+
6. ✅ **Category Mapping** - Smart mapping of categories to asset types
|
| 116 |
+
7. ✅ **Error Handling** - Graceful fallbacks if Ready Player Me fails
|
| 117 |
+
|
| 118 |
+
## 📝 Next Steps
|
| 119 |
+
|
| 120 |
+
1. **Get Ready Player Me Credentials:**
|
| 121 |
+
- Log in to Ready Player Me Studio
|
| 122 |
+
- Get your Application ID
|
| 123 |
+
- Get your Organization ID
|
| 124 |
+
- Get your Subdomain (partner name)
|
| 125 |
+
|
| 126 |
+
2. **Add Environment Variables:**
|
| 127 |
+
- Add the required env vars to `.env`
|
| 128 |
+
- Add frontend env var to `stylegptUI/.env`
|
| 129 |
+
|
| 130 |
+
3. **Run Migration:**
|
| 131 |
+
- The migration will run automatically on next startup
|
| 132 |
+
- Or run manually: `npm run migration:run`
|
| 133 |
+
|
| 134 |
+
4. **Test the Flow:**
|
| 135 |
+
- Register a new user (creates guest user)
|
| 136 |
+
- Create an avatar in Profile page
|
| 137 |
+
- Upload wardrobe items (creates assets)
|
| 138 |
+
- Ask for outfit suggestion (displays avatar with outfit)
|
| 139 |
+
|
| 140 |
+
## ⚠️ Important Notes
|
| 141 |
+
|
| 142 |
+
- Asset creation happens asynchronously and won't block uploads if it fails
|
| 143 |
+
- Avatar creation requires user to have Ready Player Me guest user (created on signup)
|
| 144 |
+
- Outfit visualization only works if user has created an avatar
|
| 145 |
+
- All Ready Player Me API calls require authentication (API key)
|
| 146 |
+
- Temporary file uploads expire after 24 hours (assets should be created quickly)
|
| 147 |
+
|
| 148 |
+
## 🐛 Troubleshooting
|
| 149 |
+
|
| 150 |
+
- **Avatar not creating:** Check if guest user was created on signup
|
| 151 |
+
- **Assets not creating:** Check READY_PLAYER_ME_APPLICATION_ID and READY_PLAYER_ME_ORGANIZATION_ID env vars
|
| 152 |
+
- **Outfit not displaying:** Check if user has avatar and items have asset IDs
|
| 153 |
+
- **API errors:** Check READY_API_KEY is correct and has proper permissions
|
| 154 |
+
|
READY_PLAYER_ME_INTEGRATION_PLAN.md
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ready Player Me Integration Plan for StyleGPT
|
| 2 |
+
|
| 3 |
+
## 📊 Current System Capabilities
|
| 4 |
+
|
| 5 |
+
### What We Have:
|
| 6 |
+
1. **User Management**
|
| 7 |
+
- User authentication (JWT)
|
| 8 |
+
- User profiles with profile pictures
|
| 9 |
+
- User entity in database
|
| 10 |
+
|
| 11 |
+
2. **Wardrobe System**
|
| 12 |
+
- Upload wardrobe items (images)
|
| 13 |
+
- Background removal
|
| 14 |
+
- FashionClip classification (category, color, item type)
|
| 15 |
+
- 3D model generation (Tencent API) - stored as base64 in DB
|
| 16 |
+
- Category normalization
|
| 17 |
+
- Style tagging (casual, formal, streetwear, sportswear)
|
| 18 |
+
- Wardrobe viewing (2D and 3D)
|
| 19 |
+
- Item deletion
|
| 20 |
+
|
| 21 |
+
3. **AI Suggestions**
|
| 22 |
+
- AI-powered outfit suggestions
|
| 23 |
+
- Extracts selected items from AI response
|
| 24 |
+
- Displays outfit with images
|
| 25 |
+
|
| 26 |
+
4. **3D Visualization**
|
| 27 |
+
- Three.js 3D wardrobe viewer
|
| 28 |
+
- Items displayed in 3D space by category
|
| 29 |
+
- Interactive controls (rotate, zoom, hover)
|
| 30 |
+
|
| 31 |
+
### Database Schema:
|
| 32 |
+
- **User**: id, email, name, password, profilePicture, createdAt
|
| 33 |
+
- **WardrobeItem**: id, imageUrl, processedImageUrl, model3dUrl, category, style, name, brand, color, userId, createdAt
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## 🎯 Ready Player Me Capabilities
|
| 38 |
+
|
| 39 |
+
### What We Can Do:
|
| 40 |
+
|
| 41 |
+
1. **User Avatars**
|
| 42 |
+
- Create guest users for StyleGPT users
|
| 43 |
+
- Store avatar IDs
|
| 44 |
+
- Get 3D avatar GLB files
|
| 45 |
+
- Get 2D avatar renders (profile pictures)
|
| 46 |
+
|
| 47 |
+
2. **Assets (Wardrobe Items)**
|
| 48 |
+
- Convert wardrobe items to Ready Player Me assets
|
| 49 |
+
- Upload GLB models to Ready Player Me
|
| 50 |
+
- Create assets with proper types (top, bottom, footwear, etc.)
|
| 51 |
+
- Equip/unequip assets to avatars
|
| 52 |
+
|
| 53 |
+
3. **Outfit Visualization**
|
| 54 |
+
- Equip multiple assets to avatar
|
| 55 |
+
- Get 3D avatar with outfit
|
| 56 |
+
- Display in 3D scene
|
| 57 |
+
|
| 58 |
+
4. **Avatar Creator**
|
| 59 |
+
- Embed iframe for avatar creation
|
| 60 |
+
- Listen for avatar export events
|
| 61 |
+
- Store avatar IDs
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
## 🚀 Best Integration Approach
|
| 66 |
+
|
| 67 |
+
### Phase 1: Foundation (Essential)
|
| 68 |
+
**Priority: HIGH**
|
| 69 |
+
|
| 70 |
+
1. **User Avatar Management**
|
| 71 |
+
- Create Ready Player Me guest user on signup
|
| 72 |
+
- Store `readyPlayerMeUserId` in User entity
|
| 73 |
+
- Add avatar creation UI (iframe integration)
|
| 74 |
+
- Store `readyPlayerMeAvatarId` in User entity
|
| 75 |
+
|
| 76 |
+
2. **Database Updates**
|
| 77 |
+
- Add `readyPlayerMeUserId` to User
|
| 78 |
+
- Add `readyPlayerMeAvatarId` to User
|
| 79 |
+
- Add `readyPlayerMeAssetId` to WardrobeItem
|
| 80 |
+
- Migration for new fields
|
| 81 |
+
|
| 82 |
+
### Phase 2: Asset Conversion (Core Feature)
|
| 83 |
+
**Priority: HIGH**
|
| 84 |
+
|
| 85 |
+
1. **Convert Wardrobe Items to Assets**
|
| 86 |
+
- When user uploads item → generate 3D model (already done)
|
| 87 |
+
- Upload GLB to Ready Player Me temporary storage
|
| 88 |
+
- Create asset with correct type mapping:
|
| 89 |
+
- shirts → "top"
|
| 90 |
+
- pants → "bottom"
|
| 91 |
+
- shoes → "footwear"
|
| 92 |
+
- glasses → "glasses"
|
| 93 |
+
- etc.
|
| 94 |
+
- Store asset ID in WardrobeItem
|
| 95 |
+
- Add asset to application
|
| 96 |
+
|
| 97 |
+
2. **Type Mapping System**
|
| 98 |
+
- Map our categories to Ready Player Me asset types
|
| 99 |
+
- Handle gender (neutral for now, can add later)
|
| 100 |
+
- Handle locked/unlocked assets
|
| 101 |
+
|
| 102 |
+
### Phase 3: Outfit Visualization (Key Feature)
|
| 103 |
+
**Priority: MEDIUM**
|
| 104 |
+
|
| 105 |
+
1. **Equip Assets to Avatar**
|
| 106 |
+
- When AI suggests outfit → get selected items
|
| 107 |
+
- Map to Ready Player Me asset IDs
|
| 108 |
+
- Equip assets to user's avatar
|
| 109 |
+
- Get updated avatar GLB with outfit
|
| 110 |
+
- Display in 3D scene
|
| 111 |
+
|
| 112 |
+
2. **3D Outfit Viewer**
|
| 113 |
+
- Load avatar with equipped outfit
|
| 114 |
+
- Display alongside wardrobe items
|
| 115 |
+
- Interactive controls
|
| 116 |
+
|
| 117 |
+
### Phase 4: Enhanced Features (Nice to Have)
|
| 118 |
+
**Priority: LOW**
|
| 119 |
+
|
| 120 |
+
1. **Profile Pictures**
|
| 121 |
+
- Use 2D avatar renders as profile pictures
|
| 122 |
+
- Update when avatar changes
|
| 123 |
+
|
| 124 |
+
2. **Avatar Updates**
|
| 125 |
+
- Listen for avatar update events
|
| 126 |
+
- Sync avatar changes
|
| 127 |
+
|
| 128 |
+
3. **Asset Management**
|
| 129 |
+
- Update assets when wardrobe items change
|
| 130 |
+
- Delete assets when items deleted
|
| 131 |
+
|
| 132 |
+
---
|
| 133 |
+
|
| 134 |
+
## 🏗️ Implementation Strategy
|
| 135 |
+
|
| 136 |
+
### Recommended Approach: **Incremental & Backward Compatible**
|
| 137 |
+
|
| 138 |
+
1. **Start with User Avatars** (Phase 1)
|
| 139 |
+
- Simplest to implement
|
| 140 |
+
- Immediate value (users can create avatars)
|
| 141 |
+
- No breaking changes
|
| 142 |
+
|
| 143 |
+
2. **Then Asset Conversion** (Phase 2)
|
| 144 |
+
- Convert existing wardrobe items gradually
|
| 145 |
+
- New uploads automatically convert
|
| 146 |
+
- Background job for existing items (optional)
|
| 147 |
+
|
| 148 |
+
3. **Finally Outfit Visualization** (Phase 3)
|
| 149 |
+
- Requires Phase 1 & 2 complete
|
| 150 |
+
- Most complex but highest value
|
| 151 |
+
|
| 152 |
+
### Why This Approach:
|
| 153 |
+
- ✅ Low risk (can test each phase independently)
|
| 154 |
+
- ✅ Backward compatible (existing features still work)
|
| 155 |
+
- ✅ Incremental value (each phase adds functionality)
|
| 156 |
+
- ✅ Easy to rollback if issues
|
| 157 |
+
- ✅ Can pause between phases
|
| 158 |
+
|
| 159 |
+
---
|
| 160 |
+
|
| 161 |
+
## 📋 Technical Implementation Plan
|
| 162 |
+
|
| 163 |
+
### Step 1: Ready Player Me Client Utility
|
| 164 |
+
- Create `src/utils/readyPlayerMe.ts`
|
| 165 |
+
- Functions for:
|
| 166 |
+
- Create guest user
|
| 167 |
+
- Get avatar GLB
|
| 168 |
+
- Get avatar 2D render
|
| 169 |
+
- Create asset
|
| 170 |
+
- Upload asset file
|
| 171 |
+
- Equip/unequip asset
|
| 172 |
+
- List assets
|
| 173 |
+
|
| 174 |
+
### Step 2: Database Migrations
|
| 175 |
+
- Migration: Add `readyPlayerMeUserId` to User
|
| 176 |
+
- Migration: Add `readyPlayerMeAvatarId` to User
|
| 177 |
+
- Migration: Add `readyPlayerMeAssetId` to WardrobeItem
|
| 178 |
+
|
| 179 |
+
### Step 3: User Registration Update
|
| 180 |
+
- On signup: Create Ready Player Me guest user
|
| 181 |
+
- Store user ID
|
| 182 |
+
|
| 183 |
+
### Step 4: Avatar Creation UI
|
| 184 |
+
- Add iframe to Profile page
|
| 185 |
+
- Listen for `v1.avatar.exported` event
|
| 186 |
+
- Store avatar ID
|
| 187 |
+
|
| 188 |
+
### Step 5: Asset Conversion (Upload Flow)
|
| 189 |
+
- After 3D model generation:
|
| 190 |
+
- Upload GLB to Ready Player Me
|
| 191 |
+
- Create asset with correct type
|
| 192 |
+
- Store asset ID
|
| 193 |
+
|
| 194 |
+
### Step 6: Outfit Visualization
|
| 195 |
+
- Update suggest endpoint:
|
| 196 |
+
- Get selected items
|
| 197 |
+
- Equip assets to avatar
|
| 198 |
+
- Return avatar GLB URL
|
| 199 |
+
- Update frontend to display avatar with outfit
|
| 200 |
+
|
| 201 |
+
---
|
| 202 |
+
|
| 203 |
+
## 🔄 Data Flow
|
| 204 |
+
|
| 205 |
+
### Current Flow:
|
| 206 |
+
```
|
| 207 |
+
User Upload → Background Removal → FashionClip → 3D Generation → Store in DB
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
### New Flow with Ready Player Me:
|
| 211 |
+
```
|
| 212 |
+
User Upload → Background Removal → FashionClip → 3D Generation →
|
| 213 |
+
→ Upload to Ready Player Me → Create Asset → Store Asset ID in DB
|
| 214 |
+
|
| 215 |
+
User Signup → Create Ready Player Me Guest User → Store User ID
|
| 216 |
+
|
| 217 |
+
User Creates Avatar → Store Avatar ID
|
| 218 |
+
|
| 219 |
+
AI Suggests Outfit → Get Asset IDs → Equip to Avatar →
|
| 220 |
+
→ Get Avatar GLB with Outfit → Display in 3D
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
---
|
| 224 |
+
|
| 225 |
+
## 🎨 UI/UX Considerations
|
| 226 |
+
|
| 227 |
+
1. **Avatar Creation**
|
| 228 |
+
- Add "Create Avatar" button in Profile
|
| 229 |
+
- Modal with iframe
|
| 230 |
+
- Show loading state
|
| 231 |
+
- Success message when avatar created
|
| 232 |
+
|
| 233 |
+
2. **Outfit Visualization**
|
| 234 |
+
- Show avatar wearing outfit in suggestion
|
| 235 |
+
- 3D viewer for outfit
|
| 236 |
+
- Option to view in full 3D scene
|
| 237 |
+
|
| 238 |
+
3. **Wardrobe 3D View**
|
| 239 |
+
- Show avatar in center
|
| 240 |
+
- Items around avatar
|
| 241 |
+
- Click item to equip on avatar
|
| 242 |
+
|
| 243 |
+
---
|
| 244 |
+
|
| 245 |
+
## ⚠️ Important Considerations
|
| 246 |
+
|
| 247 |
+
1. **API Key Security**
|
| 248 |
+
- Store in environment variables ✅ (already done)
|
| 249 |
+
- Never expose in frontend
|
| 250 |
+
- Use only in backend
|
| 251 |
+
|
| 252 |
+
2. **Error Handling**
|
| 253 |
+
- Handle Ready Player Me API failures gracefully
|
| 254 |
+
- Don't block upload if asset creation fails
|
| 255 |
+
- Log errors for debugging
|
| 256 |
+
|
| 257 |
+
3. **Performance**
|
| 258 |
+
- Asset creation is async (don't block upload)
|
| 259 |
+
- Cache avatar GLBs when possible
|
| 260 |
+
- Use LOD for 3D models
|
| 261 |
+
|
| 262 |
+
4. **Costs**
|
| 263 |
+
- Ready Player Me is free for partners ✅
|
| 264 |
+
- No additional costs expected
|
| 265 |
+
|
| 266 |
+
5. **Rate Limits**
|
| 267 |
+
- Be aware of API rate limits
|
| 268 |
+
- Implement retry logic
|
| 269 |
+
- Queue asset creation if needed
|
| 270 |
+
|
| 271 |
+
---
|
| 272 |
+
|
| 273 |
+
## 📝 Next Steps
|
| 274 |
+
|
| 275 |
+
1. ✅ Review and approve this plan
|
| 276 |
+
2. Create Ready Player Me utility functions
|
| 277 |
+
3. Add database migrations
|
| 278 |
+
4. Implement Phase 1 (User Avatars)
|
| 279 |
+
5. Test avatar creation flow
|
| 280 |
+
6. Implement Phase 2 (Asset Conversion)
|
| 281 |
+
7. Test asset creation
|
| 282 |
+
8. Implement Phase 3 (Outfit Visualization)
|
| 283 |
+
9. Test complete flow
|
| 284 |
+
10. Deploy
|
| 285 |
+
|
| 286 |
+
---
|
| 287 |
+
|
| 288 |
+
## 🎯 Success Metrics
|
| 289 |
+
|
| 290 |
+
- Users can create avatars
|
| 291 |
+
- Wardrobe items convert to assets
|
| 292 |
+
- Outfits display on avatars in 3D
|
| 293 |
+
- Smooth user experience
|
| 294 |
+
- No breaking changes to existing features
|
| 295 |
+
|
package-lock.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
| 9 |
"version": "1.0.0",
|
| 10 |
"license": "ISC",
|
| 11 |
"dependencies": {
|
|
|
|
| 12 |
"axios": "^1.13.1",
|
| 13 |
"bcrypt": "^5.1.1",
|
| 14 |
"cloudinary": "^2.8.0",
|
|
@@ -91,6 +92,17 @@
|
|
| 91 |
"node": ">=12"
|
| 92 |
}
|
| 93 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
"node_modules/@isaacs/cliui": {
|
| 95 |
"version": "8.0.2",
|
| 96 |
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
|
@@ -1293,6 +1305,11 @@
|
|
| 1293 |
"url": "https://opencollective.com/express"
|
| 1294 |
}
|
| 1295 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1296 |
"node_modules/fill-range": {
|
| 1297 |
"version": "7.1.1",
|
| 1298 |
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
|
|
|
| 9 |
"version": "1.0.0",
|
| 10 |
"license": "ISC",
|
| 11 |
"dependencies": {
|
| 12 |
+
"@gradio/client": "^2.0.1",
|
| 13 |
"axios": "^1.13.1",
|
| 14 |
"bcrypt": "^5.1.1",
|
| 15 |
"cloudinary": "^2.8.0",
|
|
|
|
| 92 |
"node": ">=12"
|
| 93 |
}
|
| 94 |
},
|
| 95 |
+
"node_modules/@gradio/client": {
|
| 96 |
+
"version": "2.0.1",
|
| 97 |
+
"resolved": "https://registry.npmjs.org/@gradio/client/-/client-2.0.1.tgz",
|
| 98 |
+
"integrity": "sha512-NLaQNj5fn+Klgtf9ESL2NhlfBo9GHYjxBCbLMXamRev36nQ/fVmhKV2V2DLV91IVTbL/gAMzeTsCmZ1Cl2CLlQ==",
|
| 99 |
+
"dependencies": {
|
| 100 |
+
"fetch-event-stream": "^0.1.5"
|
| 101 |
+
},
|
| 102 |
+
"engines": {
|
| 103 |
+
"node": ">=18.0.0"
|
| 104 |
+
}
|
| 105 |
+
},
|
| 106 |
"node_modules/@isaacs/cliui": {
|
| 107 |
"version": "8.0.2",
|
| 108 |
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
|
|
|
| 1305 |
"url": "https://opencollective.com/express"
|
| 1306 |
}
|
| 1307 |
},
|
| 1308 |
+
"node_modules/fetch-event-stream": {
|
| 1309 |
+
"version": "0.1.6",
|
| 1310 |
+
"resolved": "https://registry.npmjs.org/fetch-event-stream/-/fetch-event-stream-0.1.6.tgz",
|
| 1311 |
+
"integrity": "sha512-GREtJ5HNikdU2AXtZ6E/5bk+aslMU6ie5mPG6H9nvsdDkkHQ6m5lHwmmmDTOBexok9hApQ7EprsXCdmz9ZC68w=="
|
| 1312 |
+
},
|
| 1313 |
"node_modules/fill-range": {
|
| 1314 |
"version": "7.1.1",
|
| 1315 |
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
package.json
CHANGED
|
@@ -18,6 +18,7 @@
|
|
| 18 |
"author": "",
|
| 19 |
"license": "ISC",
|
| 20 |
"dependencies": {
|
|
|
|
| 21 |
"axios": "^1.13.1",
|
| 22 |
"bcrypt": "^5.1.1",
|
| 23 |
"cloudinary": "^2.8.0",
|
|
|
|
| 18 |
"author": "",
|
| 19 |
"license": "ISC",
|
| 20 |
"dependencies": {
|
| 21 |
+
"@gradio/client": "^2.0.1",
|
| 22 |
"axios": "^1.13.1",
|
| 23 |
"bcrypt": "^5.1.1",
|
| 24 |
"cloudinary": "^2.8.0",
|
src/entity/User.ts
CHANGED
|
@@ -18,6 +18,12 @@ export class User {
|
|
| 18 |
@Column({ nullable: true })
|
| 19 |
profilePicture?: string;
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
@OneToMany(() => WardrobeItem, (wardrobeItem) => wardrobeItem.user)
|
| 22 |
wardrobeItems!: WardrobeItem[];
|
| 23 |
|
|
|
|
| 18 |
@Column({ nullable: true })
|
| 19 |
profilePicture?: string;
|
| 20 |
|
| 21 |
+
@Column({ nullable: true })
|
| 22 |
+
readyPlayerMeUserId?: string;
|
| 23 |
+
|
| 24 |
+
@Column({ nullable: true })
|
| 25 |
+
readyPlayerMeAvatarId?: string;
|
| 26 |
+
|
| 27 |
@OneToMany(() => WardrobeItem, (wardrobeItem) => wardrobeItem.user)
|
| 28 |
wardrobeItems!: WardrobeItem[];
|
| 29 |
|
src/entity/WardrobeItem.ts
CHANGED
|
@@ -12,6 +12,9 @@ export class WardrobeItem {
|
|
| 12 |
@Column({ nullable: true })
|
| 13 |
processedImageUrl?: string;
|
| 14 |
|
|
|
|
|
|
|
|
|
|
| 15 |
@Column()
|
| 16 |
category!: string;
|
| 17 |
|
|
@@ -27,6 +30,9 @@ export class WardrobeItem {
|
|
| 27 |
@Column({ nullable: true })
|
| 28 |
color?: string;
|
| 29 |
|
|
|
|
|
|
|
|
|
|
| 30 |
@ManyToOne(() => User, (user) => user.wardrobeItems)
|
| 31 |
@JoinColumn({ name: "userId" })
|
| 32 |
user!: User;
|
|
|
|
| 12 |
@Column({ nullable: true })
|
| 13 |
processedImageUrl?: string;
|
| 14 |
|
| 15 |
+
@Column({ nullable: true, type: "text" })
|
| 16 |
+
model3dUrl?: string;
|
| 17 |
+
|
| 18 |
@Column()
|
| 19 |
category!: string;
|
| 20 |
|
|
|
|
| 30 |
@Column({ nullable: true })
|
| 31 |
color?: string;
|
| 32 |
|
| 33 |
+
@Column({ nullable: true })
|
| 34 |
+
readyPlayerMeAssetId?: string;
|
| 35 |
+
|
| 36 |
@ManyToOne(() => User, (user) => user.wardrobeItems)
|
| 37 |
@JoinColumn({ name: "userId" })
|
| 38 |
user!: User;
|
src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import uploadRoute from "./routes/upload";
|
|
| 10 |
import suggestRoute from "./routes/suggest";
|
| 11 |
import wardrobeRoute from "./routes/wardrobe";
|
| 12 |
import profileRoute from "./routes/profile";
|
|
|
|
| 13 |
|
| 14 |
dotenv.config();
|
| 15 |
|
|
@@ -89,6 +90,8 @@ const swaggerOptions: swaggerJsdoc.Options = {
|
|
| 89 |
id: { type: "integer" },
|
| 90 |
name: { type: "string" },
|
| 91 |
email: { type: "string", format: "email" },
|
|
|
|
|
|
|
| 92 |
createdAt: { type: "string", format: "date-time" }
|
| 93 |
}
|
| 94 |
},
|
|
@@ -162,6 +165,7 @@ const swaggerOptions: swaggerJsdoc.Options = {
|
|
| 162 |
{ name: "Profile", description: "User profile management" },
|
| 163 |
{ name: "Upload", description: "Wardrobe item upload and classification" },
|
| 164 |
{ name: "Suggest", description: "AI-powered outfit suggestions" },
|
|
|
|
| 165 |
{ name: "Health", description: "Health check endpoints" }
|
| 166 |
]
|
| 167 |
},
|
|
@@ -213,6 +217,7 @@ app.use("/api/profile", profileRoute);
|
|
| 213 |
app.use("/api/upload", uploadRoute);
|
| 214 |
app.use("/api/suggest", suggestRoute);
|
| 215 |
app.use("/api/wardrobe", wardrobeRoute);
|
|
|
|
| 216 |
|
| 217 |
const PORT = process.env.PORT || 7860;
|
| 218 |
|
|
|
|
| 10 |
import suggestRoute from "./routes/suggest";
|
| 11 |
import wardrobeRoute from "./routes/wardrobe";
|
| 12 |
import profileRoute from "./routes/profile";
|
| 13 |
+
import avatarRoute from "./routes/avatar";
|
| 14 |
|
| 15 |
dotenv.config();
|
| 16 |
|
|
|
|
| 90 |
id: { type: "integer" },
|
| 91 |
name: { type: "string" },
|
| 92 |
email: { type: "string", format: "email" },
|
| 93 |
+
profilePicture: { type: "string", format: "uri", description: "Avatar render URL or base64 image" },
|
| 94 |
+
readyPlayerMeAvatarId: { type: "string", description: "Ready Player Me avatar ID" },
|
| 95 |
createdAt: { type: "string", format: "date-time" }
|
| 96 |
}
|
| 97 |
},
|
|
|
|
| 165 |
{ name: "Profile", description: "User profile management" },
|
| 166 |
{ name: "Upload", description: "Wardrobe item upload and classification" },
|
| 167 |
{ name: "Suggest", description: "AI-powered outfit suggestions" },
|
| 168 |
+
{ name: "Avatar", description: "Ready Player Me avatar management" },
|
| 169 |
{ name: "Health", description: "Health check endpoints" }
|
| 170 |
]
|
| 171 |
},
|
|
|
|
| 217 |
app.use("/api/upload", uploadRoute);
|
| 218 |
app.use("/api/suggest", suggestRoute);
|
| 219 |
app.use("/api/wardrobe", wardrobeRoute);
|
| 220 |
+
app.use("/api/avatar", avatarRoute);
|
| 221 |
|
| 222 |
const PORT = process.env.PORT || 7860;
|
| 223 |
|
src/migrations/1700000000004-AddModel3DUrl.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { MigrationInterface, QueryRunner } from "typeorm";
|
| 2 |
+
|
| 3 |
+
export class AddModel3DUrl1700000000004 implements MigrationInterface {
|
| 4 |
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
| 5 |
+
await queryRunner.query(`
|
| 6 |
+
ALTER TABLE "wardrobe_item"
|
| 7 |
+
ADD COLUMN IF NOT EXISTS "model3dUrl" TEXT
|
| 8 |
+
`);
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
| 12 |
+
await queryRunner.query(`
|
| 13 |
+
ALTER TABLE "wardrobe_item"
|
| 14 |
+
DROP COLUMN IF EXISTS "model3dUrl"
|
| 15 |
+
`);
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
src/migrations/1700000000005-AddReadyPlayerMeFields.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { MigrationInterface, QueryRunner } from "typeorm";
|
| 2 |
+
|
| 3 |
+
export class AddReadyPlayerMeFields1700000000005 implements MigrationInterface {
|
| 4 |
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
| 5 |
+
await queryRunner.query(`
|
| 6 |
+
ALTER TABLE "user"
|
| 7 |
+
ADD COLUMN IF NOT EXISTS "readyPlayerMeUserId" VARCHAR
|
| 8 |
+
`);
|
| 9 |
+
|
| 10 |
+
await queryRunner.query(`
|
| 11 |
+
ALTER TABLE "user"
|
| 12 |
+
ADD COLUMN IF NOT EXISTS "readyPlayerMeAvatarId" VARCHAR
|
| 13 |
+
`);
|
| 14 |
+
|
| 15 |
+
await queryRunner.query(`
|
| 16 |
+
ALTER TABLE "wardrobe_item"
|
| 17 |
+
ADD COLUMN IF NOT EXISTS "readyPlayerMeAssetId" VARCHAR
|
| 18 |
+
`);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
| 22 |
+
await queryRunner.query(`
|
| 23 |
+
ALTER TABLE "wardrobe_item"
|
| 24 |
+
DROP COLUMN IF EXISTS "readyPlayerMeAssetId"
|
| 25 |
+
`);
|
| 26 |
+
|
| 27 |
+
await queryRunner.query(`
|
| 28 |
+
ALTER TABLE "user"
|
| 29 |
+
DROP COLUMN IF EXISTS "readyPlayerMeAvatarId"
|
| 30 |
+
`);
|
| 31 |
+
|
| 32 |
+
await queryRunner.query(`
|
| 33 |
+
ALTER TABLE "user"
|
| 34 |
+
DROP COLUMN IF EXISTS "readyPlayerMeUserId"
|
| 35 |
+
`);
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
src/routes/auth.ts
CHANGED
|
@@ -3,6 +3,7 @@ import bcrypt from "bcrypt";
|
|
| 3 |
import jwt from "jsonwebtoken";
|
| 4 |
import { AppDataSource } from "../utils/dataSource";
|
| 5 |
import { User } from "../entity/User";
|
|
|
|
| 6 |
import dotenv from "dotenv";
|
| 7 |
dotenv.config();
|
| 8 |
|
|
@@ -70,6 +71,20 @@ router.post("/register", async (req, res) => {
|
|
| 70 |
|
| 71 |
await userRepo.save(newUser);
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
const jwtSecret = process.env.JWT_SECRET || "your-secret-key-change-in-production";
|
| 74 |
const token = jwt.sign(
|
| 75 |
{ userId: newUser.id, email: newUser.email },
|
|
|
|
| 3 |
import jwt from "jsonwebtoken";
|
| 4 |
import { AppDataSource } from "../utils/dataSource";
|
| 5 |
import { User } from "../entity/User";
|
| 6 |
+
import { readyPlayerMeClient } from "../utils/readyPlayerMe";
|
| 7 |
import dotenv from "dotenv";
|
| 8 |
dotenv.config();
|
| 9 |
|
|
|
|
| 71 |
|
| 72 |
await userRepo.save(newUser);
|
| 73 |
|
| 74 |
+
const applicationId = process.env.READY_PLAYER_ME_APPLICATION_ID;
|
| 75 |
+
if (applicationId) {
|
| 76 |
+
try {
|
| 77 |
+
const rpmUser = await readyPlayerMeClient.createGuestUser(applicationId);
|
| 78 |
+
if (rpmUser) {
|
| 79 |
+
newUser.readyPlayerMeUserId = rpmUser.id;
|
| 80 |
+
await userRepo.save(newUser);
|
| 81 |
+
console.log(`Created Ready Player Me guest user: ${rpmUser.id} for user ${newUser.id}`);
|
| 82 |
+
}
|
| 83 |
+
} catch (rpmError: any) {
|
| 84 |
+
console.warn("Failed to create Ready Player Me guest user:", rpmError.message);
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
const jwtSecret = process.env.JWT_SECRET || "your-secret-key-change-in-production";
|
| 89 |
const token = jwt.sign(
|
| 90 |
{ userId: newUser.id, email: newUser.email },
|
src/routes/avatar.ts
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express from "express";
|
| 2 |
+
import { AppDataSource } from "../utils/dataSource";
|
| 3 |
+
import { User } from "../entity/User";
|
| 4 |
+
import { authenticateToken, AuthRequest } from "../middleware/auth";
|
| 5 |
+
import { readyPlayerMeClient } from "../utils/readyPlayerMe";
|
| 6 |
+
|
| 7 |
+
const router = express.Router();
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* @openapi
|
| 11 |
+
* /api/avatar/create:
|
| 12 |
+
* post:
|
| 13 |
+
* summary: Save Ready Player Me avatar ID for user
|
| 14 |
+
* tags: [Avatar]
|
| 15 |
+
* security:
|
| 16 |
+
* - bearerAuth: []
|
| 17 |
+
* requestBody:
|
| 18 |
+
* required: true
|
| 19 |
+
* content:
|
| 20 |
+
* application/json:
|
| 21 |
+
* schema:
|
| 22 |
+
* type: object
|
| 23 |
+
* required:
|
| 24 |
+
* - avatarId
|
| 25 |
+
* properties:
|
| 26 |
+
* avatarId:
|
| 27 |
+
* type: string
|
| 28 |
+
* description: Ready Player Me avatar ID
|
| 29 |
+
* responses:
|
| 30 |
+
* 200:
|
| 31 |
+
* description: Avatar ID saved successfully
|
| 32 |
+
* 400:
|
| 33 |
+
* description: Bad request (missing avatarId)
|
| 34 |
+
* 401:
|
| 35 |
+
* description: Unauthorized
|
| 36 |
+
* 404:
|
| 37 |
+
* description: User not found
|
| 38 |
+
* 500:
|
| 39 |
+
* description: Server error
|
| 40 |
+
*/
|
| 41 |
+
router.post("/create", authenticateToken, async (req: AuthRequest, res) => {
|
| 42 |
+
try {
|
| 43 |
+
const userId = req.userId!;
|
| 44 |
+
const { avatarId } = req.body;
|
| 45 |
+
|
| 46 |
+
if (!avatarId) {
|
| 47 |
+
return res.status(400).json({
|
| 48 |
+
success: false,
|
| 49 |
+
error: "avatarId is required",
|
| 50 |
+
});
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const userRepo = AppDataSource.getRepository(User);
|
| 54 |
+
const user = await userRepo.findOne({ where: { id: userId } });
|
| 55 |
+
|
| 56 |
+
if (!user) {
|
| 57 |
+
return res.status(404).json({
|
| 58 |
+
success: false,
|
| 59 |
+
error: "User not found",
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
user.readyPlayerMeAvatarId = avatarId;
|
| 64 |
+
await userRepo.save(user);
|
| 65 |
+
|
| 66 |
+
res.json({
|
| 67 |
+
success: true,
|
| 68 |
+
message: "Avatar ID saved successfully",
|
| 69 |
+
avatarId,
|
| 70 |
+
});
|
| 71 |
+
} catch (error: any) {
|
| 72 |
+
console.error("Avatar creation error:", error);
|
| 73 |
+
res.status(500).json({
|
| 74 |
+
success: false,
|
| 75 |
+
error: error.message || "Failed to save avatar ID",
|
| 76 |
+
});
|
| 77 |
+
}
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
/**
|
| 81 |
+
* @openapi
|
| 82 |
+
* /api/avatar/glb:
|
| 83 |
+
* get:
|
| 84 |
+
* summary: Get 3D avatar GLB file URL
|
| 85 |
+
* tags: [Avatar]
|
| 86 |
+
* security:
|
| 87 |
+
* - bearerAuth: []
|
| 88 |
+
* parameters:
|
| 89 |
+
* - in: query
|
| 90 |
+
* name: quality
|
| 91 |
+
* schema:
|
| 92 |
+
* type: string
|
| 93 |
+
* enum: [low, medium, high, ultra]
|
| 94 |
+
* description: Avatar quality preset
|
| 95 |
+
* - in: query
|
| 96 |
+
* name: lod
|
| 97 |
+
* schema:
|
| 98 |
+
* type: integer
|
| 99 |
+
* enum: [0, 1, 2]
|
| 100 |
+
* description: Level of detail (0=full, 1=50%, 2=25%)
|
| 101 |
+
* - in: query
|
| 102 |
+
* name: textureAtlas
|
| 103 |
+
* schema:
|
| 104 |
+
* type: string
|
| 105 |
+
* description: Texture atlas size or "none"
|
| 106 |
+
* - in: query
|
| 107 |
+
* name: textureFormat
|
| 108 |
+
* schema:
|
| 109 |
+
* type: string
|
| 110 |
+
* enum: [webp, jpeg, png]
|
| 111 |
+
* description: Texture format
|
| 112 |
+
* - in: query
|
| 113 |
+
* name: useDracoMeshCompression
|
| 114 |
+
* schema:
|
| 115 |
+
* type: boolean
|
| 116 |
+
* description: Enable Draco mesh compression
|
| 117 |
+
* responses:
|
| 118 |
+
* 200:
|
| 119 |
+
* description: Avatar GLB URL retrieved successfully
|
| 120 |
+
* 401:
|
| 121 |
+
* description: Unauthorized
|
| 122 |
+
* 404:
|
| 123 |
+
* description: Avatar not found
|
| 124 |
+
* 500:
|
| 125 |
+
* description: Server error
|
| 126 |
+
*/
|
| 127 |
+
router.get("/glb", authenticateToken, async (req: AuthRequest, res) => {
|
| 128 |
+
try {
|
| 129 |
+
const userId = req.userId!;
|
| 130 |
+
const { quality, lod, textureAtlas, textureFormat, useDracoMeshCompression } = req.query;
|
| 131 |
+
|
| 132 |
+
const userRepo = AppDataSource.getRepository(User);
|
| 133 |
+
const user = await userRepo.findOne({ where: { id: userId } });
|
| 134 |
+
|
| 135 |
+
if (!user || !user.readyPlayerMeAvatarId) {
|
| 136 |
+
return res.status(404).json({
|
| 137 |
+
success: false,
|
| 138 |
+
error: "Avatar not found. Please create an avatar first.",
|
| 139 |
+
});
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
const avatarUrl = await readyPlayerMeClient.getAvatarGLB(user.readyPlayerMeAvatarId, {
|
| 143 |
+
quality: quality as any,
|
| 144 |
+
lod: lod ? parseInt(lod as string) : undefined,
|
| 145 |
+
textureAtlas: textureAtlas === "none" ? "none" : textureAtlas ? parseInt(textureAtlas as string) : undefined,
|
| 146 |
+
textureFormat: textureFormat as any,
|
| 147 |
+
useDracoMeshCompression: useDracoMeshCompression === "true",
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
res.json({
|
| 151 |
+
success: true,
|
| 152 |
+
avatarUrl,
|
| 153 |
+
avatarId: user.readyPlayerMeAvatarId,
|
| 154 |
+
});
|
| 155 |
+
} catch (error: any) {
|
| 156 |
+
console.error("Get avatar GLB error:", error);
|
| 157 |
+
res.status(500).json({
|
| 158 |
+
success: false,
|
| 159 |
+
error: error.message || "Failed to get avatar GLB",
|
| 160 |
+
});
|
| 161 |
+
}
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
/**
|
| 165 |
+
* @openapi
|
| 166 |
+
* /api/avatar/render:
|
| 167 |
+
* get:
|
| 168 |
+
* summary: Get 2D avatar render (portrait) URL
|
| 169 |
+
* tags: [Avatar]
|
| 170 |
+
* security:
|
| 171 |
+
* - bearerAuth: []
|
| 172 |
+
* parameters:
|
| 173 |
+
* - in: query
|
| 174 |
+
* name: size
|
| 175 |
+
* schema:
|
| 176 |
+
* type: integer
|
| 177 |
+
* minimum: 1
|
| 178 |
+
* maximum: 1024
|
| 179 |
+
* description: Image size in pixels
|
| 180 |
+
* - in: query
|
| 181 |
+
* name: quality
|
| 182 |
+
* schema:
|
| 183 |
+
* type: integer
|
| 184 |
+
* minimum: 0
|
| 185 |
+
* maximum: 100
|
| 186 |
+
* description: Image compression quality
|
| 187 |
+
* - in: query
|
| 188 |
+
* name: camera
|
| 189 |
+
* schema:
|
| 190 |
+
* type: string
|
| 191 |
+
* enum: [portrait, fullbody, fit]
|
| 192 |
+
* description: Camera preset
|
| 193 |
+
* - in: query
|
| 194 |
+
* name: background
|
| 195 |
+
* schema:
|
| 196 |
+
* type: string
|
| 197 |
+
* description: Background color (RGB format)
|
| 198 |
+
* - in: query
|
| 199 |
+
* name: expression
|
| 200 |
+
* schema:
|
| 201 |
+
* type: string
|
| 202 |
+
* description: Facial expression
|
| 203 |
+
* - in: query
|
| 204 |
+
* name: pose
|
| 205 |
+
* schema:
|
| 206 |
+
* type: string
|
| 207 |
+
* description: Avatar pose
|
| 208 |
+
* responses:
|
| 209 |
+
* 200:
|
| 210 |
+
* description: Avatar render URL retrieved successfully
|
| 211 |
+
* 401:
|
| 212 |
+
* description: Unauthorized
|
| 213 |
+
* 404:
|
| 214 |
+
* description: Avatar not found
|
| 215 |
+
* 500:
|
| 216 |
+
* description: Server error
|
| 217 |
+
*/
|
| 218 |
+
router.get("/render", authenticateToken, async (req: AuthRequest, res) => {
|
| 219 |
+
try {
|
| 220 |
+
const userId = req.userId!;
|
| 221 |
+
const { size, quality, camera, background, expression, pose } = req.query;
|
| 222 |
+
|
| 223 |
+
const userRepo = AppDataSource.getRepository(User);
|
| 224 |
+
const user = await userRepo.findOne({ where: { id: userId } });
|
| 225 |
+
|
| 226 |
+
if (!user || !user.readyPlayerMeAvatarId) {
|
| 227 |
+
return res.status(404).json({
|
| 228 |
+
success: false,
|
| 229 |
+
error: "Avatar not found. Please create an avatar first.",
|
| 230 |
+
});
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
const renderUrl = await readyPlayerMeClient.getAvatar2DRender(user.readyPlayerMeAvatarId, {
|
| 234 |
+
size: size ? parseInt(size as string) : undefined,
|
| 235 |
+
quality: quality ? parseInt(quality as string) : undefined,
|
| 236 |
+
camera: camera as any,
|
| 237 |
+
background: background as string,
|
| 238 |
+
expression: expression as string,
|
| 239 |
+
pose: pose as string,
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
res.json({
|
| 243 |
+
success: true,
|
| 244 |
+
renderUrl,
|
| 245 |
+
avatarId: user.readyPlayerMeAvatarId,
|
| 246 |
+
});
|
| 247 |
+
} catch (error: any) {
|
| 248 |
+
console.error("Get avatar render error:", error);
|
| 249 |
+
res.status(500).json({
|
| 250 |
+
success: false,
|
| 251 |
+
error: error.message || "Failed to get avatar render",
|
| 252 |
+
});
|
| 253 |
+
}
|
| 254 |
+
});
|
| 255 |
+
|
| 256 |
+
/**
|
| 257 |
+
* @openapi
|
| 258 |
+
* /api/avatar/metadata:
|
| 259 |
+
* get:
|
| 260 |
+
* summary: Get avatar metadata
|
| 261 |
+
* tags: [Avatar]
|
| 262 |
+
* security:
|
| 263 |
+
* - bearerAuth: []
|
| 264 |
+
* responses:
|
| 265 |
+
* 200:
|
| 266 |
+
* description: Avatar metadata retrieved successfully
|
| 267 |
+
* 401:
|
| 268 |
+
* description: Unauthorized
|
| 269 |
+
* 404:
|
| 270 |
+
* description: Avatar not found
|
| 271 |
+
* 500:
|
| 272 |
+
* description: Server error
|
| 273 |
+
*/
|
| 274 |
+
router.get("/metadata", authenticateToken, async (req: AuthRequest, res) => {
|
| 275 |
+
try {
|
| 276 |
+
const userId = req.userId!;
|
| 277 |
+
|
| 278 |
+
const userRepo = AppDataSource.getRepository(User);
|
| 279 |
+
const user = await userRepo.findOne({ where: { id: userId } });
|
| 280 |
+
|
| 281 |
+
if (!user || !user.readyPlayerMeAvatarId) {
|
| 282 |
+
return res.status(404).json({
|
| 283 |
+
success: false,
|
| 284 |
+
error: "Avatar not found. Please create an avatar first.",
|
| 285 |
+
});
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
const metadata = await readyPlayerMeClient.getAvatarMetadata(user.readyPlayerMeAvatarId);
|
| 289 |
+
|
| 290 |
+
if (!metadata) {
|
| 291 |
+
return res.status(404).json({
|
| 292 |
+
success: false,
|
| 293 |
+
error: "Avatar metadata not found",
|
| 294 |
+
});
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
res.json({
|
| 298 |
+
success: true,
|
| 299 |
+
metadata,
|
| 300 |
+
});
|
| 301 |
+
} catch (error: any) {
|
| 302 |
+
console.error("Get avatar metadata error:", error);
|
| 303 |
+
res.status(500).json({
|
| 304 |
+
success: false,
|
| 305 |
+
error: error.message || "Failed to get avatar metadata",
|
| 306 |
+
});
|
| 307 |
+
}
|
| 308 |
+
});
|
| 309 |
+
|
| 310 |
+
/**
|
| 311 |
+
* @openapi
|
| 312 |
+
* /api/avatar/try-on:
|
| 313 |
+
* post:
|
| 314 |
+
* summary: Equip assets to avatar and get GLB URL with outfit
|
| 315 |
+
* tags: [Avatar]
|
| 316 |
+
* security:
|
| 317 |
+
* - bearerAuth: []
|
| 318 |
+
* requestBody:
|
| 319 |
+
* required: true
|
| 320 |
+
* content:
|
| 321 |
+
* application/json:
|
| 322 |
+
* schema:
|
| 323 |
+
* type: object
|
| 324 |
+
* required:
|
| 325 |
+
* - assetIds
|
| 326 |
+
* properties:
|
| 327 |
+
* assetIds:
|
| 328 |
+
* type: array
|
| 329 |
+
* items:
|
| 330 |
+
* type: string
|
| 331 |
+
* description: Array of Ready Player Me asset IDs to equip
|
| 332 |
+
* quality:
|
| 333 |
+
* type: string
|
| 334 |
+
* enum: [low, medium, high, ultra]
|
| 335 |
+
* description: Avatar quality preset
|
| 336 |
+
* responses:
|
| 337 |
+
* 200:
|
| 338 |
+
* description: Avatar GLB URL with equipped outfit
|
| 339 |
+
* 400:
|
| 340 |
+
* description: Bad request
|
| 341 |
+
* 401:
|
| 342 |
+
* description: Unauthorized
|
| 343 |
+
* 404:
|
| 344 |
+
* description: Avatar not found
|
| 345 |
+
* 500:
|
| 346 |
+
* description: Server error
|
| 347 |
+
*/
|
| 348 |
+
router.post("/try-on", authenticateToken, async (req: AuthRequest, res) => {
|
| 349 |
+
try {
|
| 350 |
+
const userId = req.userId!;
|
| 351 |
+
const { assetIds, quality } = req.body;
|
| 352 |
+
|
| 353 |
+
if (!assetIds || !Array.isArray(assetIds) || assetIds.length === 0) {
|
| 354 |
+
return res.status(400).json({
|
| 355 |
+
success: false,
|
| 356 |
+
error: "assetIds array is required",
|
| 357 |
+
});
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
const userRepo = AppDataSource.getRepository(User);
|
| 361 |
+
const user = await userRepo.findOne({ where: { id: userId } });
|
| 362 |
+
|
| 363 |
+
if (!user || !user.readyPlayerMeAvatarId) {
|
| 364 |
+
return res.status(404).json({
|
| 365 |
+
success: false,
|
| 366 |
+
error: "Avatar not found. Please create an avatar first.",
|
| 367 |
+
});
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
// Equip all assets to avatar
|
| 371 |
+
const equipPromises = assetIds.map((assetId: string) =>
|
| 372 |
+
readyPlayerMeClient.equipAsset(user.readyPlayerMeAvatarId!, assetId)
|
| 373 |
+
);
|
| 374 |
+
|
| 375 |
+
const results = await Promise.all(equipPromises);
|
| 376 |
+
const allEquipped = results.every((result) => result === true);
|
| 377 |
+
|
| 378 |
+
if (!allEquipped) {
|
| 379 |
+
console.warn("Some assets failed to equip");
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
// Get avatar GLB URL with equipped outfit
|
| 383 |
+
const avatarUrl = await readyPlayerMeClient.getAvatarGLB(user.readyPlayerMeAvatarId, {
|
| 384 |
+
quality: quality || "medium",
|
| 385 |
+
lod: 1,
|
| 386 |
+
textureFormat: "webp",
|
| 387 |
+
useDracoMeshCompression: true,
|
| 388 |
+
});
|
| 389 |
+
|
| 390 |
+
res.json({
|
| 391 |
+
success: true,
|
| 392 |
+
avatarUrl,
|
| 393 |
+
avatarId: user.readyPlayerMeAvatarId,
|
| 394 |
+
equippedAssets: assetIds,
|
| 395 |
+
});
|
| 396 |
+
} catch (error: any) {
|
| 397 |
+
console.error("Try on avatar error:", error);
|
| 398 |
+
res.status(500).json({
|
| 399 |
+
success: false,
|
| 400 |
+
error: error.message || "Failed to equip outfit on avatar",
|
| 401 |
+
});
|
| 402 |
+
}
|
| 403 |
+
});
|
| 404 |
+
|
| 405 |
+
export default router;
|
| 406 |
+
|
src/routes/profile.ts
CHANGED
|
@@ -1,26 +1,11 @@
|
|
| 1 |
import express from "express";
|
| 2 |
-
import multer from "multer";
|
| 3 |
import { AppDataSource } from "../utils/dataSource";
|
| 4 |
import { User } from "../entity/User";
|
| 5 |
import { authenticateToken, AuthRequest } from "../middleware/auth";
|
|
|
|
| 6 |
|
| 7 |
const router = express.Router();
|
| 8 |
|
| 9 |
-
// Configure multer for memory storage
|
| 10 |
-
const upload = multer({
|
| 11 |
-
storage: multer.memoryStorage(),
|
| 12 |
-
limits: {
|
| 13 |
-
fileSize: 5 * 1024 * 1024, // 5MB limit for profile pictures
|
| 14 |
-
},
|
| 15 |
-
fileFilter: (req, file, cb) => {
|
| 16 |
-
if (file.mimetype.startsWith("image/")) {
|
| 17 |
-
cb(null, true);
|
| 18 |
-
} else {
|
| 19 |
-
cb(new Error("Only image files are allowed"));
|
| 20 |
-
}
|
| 21 |
-
},
|
| 22 |
-
});
|
| 23 |
-
|
| 24 |
/**
|
| 25 |
* @openapi
|
| 26 |
* /api/profile:
|
|
@@ -55,7 +40,7 @@ router.get("/", authenticateToken, async (req: AuthRequest, res) => {
|
|
| 55 |
const userRepo = AppDataSource.getRepository(User);
|
| 56 |
const user = await userRepo.findOne({
|
| 57 |
where: { id: userId },
|
| 58 |
-
select: ["id", "name", "email", "profilePicture", "createdAt"],
|
| 59 |
});
|
| 60 |
|
| 61 |
if (!user) {
|
|
@@ -65,13 +50,30 @@ router.get("/", authenticateToken, async (req: AuthRequest, res) => {
|
|
| 65 |
});
|
| 66 |
}
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
res.json({
|
| 69 |
success: true,
|
| 70 |
user: {
|
| 71 |
id: user.id,
|
| 72 |
name: user.name,
|
| 73 |
email: user.email,
|
| 74 |
-
profilePicture:
|
|
|
|
|
|
|
| 75 |
createdAt: user.createdAt,
|
| 76 |
},
|
| 77 |
});
|
|
@@ -88,21 +90,20 @@ router.get("/", authenticateToken, async (req: AuthRequest, res) => {
|
|
| 88 |
* @openapi
|
| 89 |
* /api/profile:
|
| 90 |
* put:
|
| 91 |
-
* summary: Update user profile (name
|
| 92 |
* tags: [Profile]
|
| 93 |
* security:
|
| 94 |
* - bearerAuth: []
|
| 95 |
* requestBody:
|
|
|
|
| 96 |
* content:
|
| 97 |
-
*
|
| 98 |
* schema:
|
| 99 |
* type: object
|
| 100 |
* properties:
|
| 101 |
* name:
|
| 102 |
* type: string
|
| 103 |
-
*
|
| 104 |
-
* type: string
|
| 105 |
-
* format: binary
|
| 106 |
* responses:
|
| 107 |
* 200:
|
| 108 |
* description: Profile updated successfully
|
|
@@ -113,11 +114,10 @@ router.get("/", authenticateToken, async (req: AuthRequest, res) => {
|
|
| 113 |
* 500:
|
| 114 |
* description: Server error
|
| 115 |
*/
|
| 116 |
-
router.put("/", authenticateToken,
|
| 117 |
try {
|
| 118 |
const userId = req.userId!;
|
| 119 |
const { name } = req.body;
|
| 120 |
-
const file = req.file;
|
| 121 |
|
| 122 |
const userRepo = AppDataSource.getRepository(User);
|
| 123 |
const user = await userRepo.findOne({ where: { id: userId } });
|
|
@@ -134,26 +134,18 @@ router.put("/", authenticateToken, upload.single("profilePicture"), async (req:
|
|
| 134 |
user.name = name.trim();
|
| 135 |
}
|
| 136 |
|
| 137 |
-
//
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
}
|
| 141 |
-
|
| 142 |
-
// Handle profile picture upload
|
| 143 |
-
if (file) {
|
| 144 |
try {
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
user.profilePicture = base64Image;
|
| 150 |
-
console.log("✅ Profile picture converted to base64 and stored");
|
| 151 |
-
} catch (uploadError: any) {
|
| 152 |
-
console.error("Profile picture upload error:", uploadError);
|
| 153 |
-
return res.status(500).json({
|
| 154 |
-
success: false,
|
| 155 |
-
error: "Failed to process profile picture",
|
| 156 |
});
|
|
|
|
|
|
|
|
|
|
| 157 |
}
|
| 158 |
}
|
| 159 |
|
|
@@ -166,6 +158,8 @@ router.put("/", authenticateToken, upload.single("profilePicture"), async (req:
|
|
| 166 |
name: user.name,
|
| 167 |
email: user.email,
|
| 168 |
profilePicture: user.profilePicture || undefined,
|
|
|
|
|
|
|
| 169 |
createdAt: user.createdAt,
|
| 170 |
},
|
| 171 |
});
|
|
|
|
| 1 |
import express from "express";
|
|
|
|
| 2 |
import { AppDataSource } from "../utils/dataSource";
|
| 3 |
import { User } from "../entity/User";
|
| 4 |
import { authenticateToken, AuthRequest } from "../middleware/auth";
|
| 5 |
+
import { readyPlayerMeClient } from "../utils/readyPlayerMe";
|
| 6 |
|
| 7 |
const router = express.Router();
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
/**
|
| 10 |
* @openapi
|
| 11 |
* /api/profile:
|
|
|
|
| 40 |
const userRepo = AppDataSource.getRepository(User);
|
| 41 |
const user = await userRepo.findOne({
|
| 42 |
where: { id: userId },
|
| 43 |
+
select: ["id", "name", "email", "profilePicture", "readyPlayerMeAvatarId", "createdAt"],
|
| 44 |
});
|
| 45 |
|
| 46 |
if (!user) {
|
|
|
|
| 50 |
});
|
| 51 |
}
|
| 52 |
|
| 53 |
+
let profilePicture = user.profilePicture;
|
| 54 |
+
|
| 55 |
+
if (!profilePicture && user.readyPlayerMeAvatarId) {
|
| 56 |
+
try {
|
| 57 |
+
const avatarRenderUrl = await readyPlayerMeClient.getAvatar2DRender(user.readyPlayerMeAvatarId, {
|
| 58 |
+
size: 400,
|
| 59 |
+
quality: 90,
|
| 60 |
+
camera: "portrait",
|
| 61 |
+
});
|
| 62 |
+
profilePicture = avatarRenderUrl;
|
| 63 |
+
} catch (error: any) {
|
| 64 |
+
console.warn("Failed to get avatar render for profile picture:", error.message);
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
res.json({
|
| 69 |
success: true,
|
| 70 |
user: {
|
| 71 |
id: user.id,
|
| 72 |
name: user.name,
|
| 73 |
email: user.email,
|
| 74 |
+
profilePicture: profilePicture || undefined,
|
| 75 |
+
readyPlayerMeAvatarId: user.readyPlayerMeAvatarId || undefined,
|
| 76 |
+
readyPlayerMeUserId: user.readyPlayerMeUserId || undefined,
|
| 77 |
createdAt: user.createdAt,
|
| 78 |
},
|
| 79 |
});
|
|
|
|
| 90 |
* @openapi
|
| 91 |
* /api/profile:
|
| 92 |
* put:
|
| 93 |
+
* summary: Update user profile (name only - profile picture uses avatar render)
|
| 94 |
* tags: [Profile]
|
| 95 |
* security:
|
| 96 |
* - bearerAuth: []
|
| 97 |
* requestBody:
|
| 98 |
+
* required: true
|
| 99 |
* content:
|
| 100 |
+
* application/json:
|
| 101 |
* schema:
|
| 102 |
* type: object
|
| 103 |
* properties:
|
| 104 |
* name:
|
| 105 |
* type: string
|
| 106 |
+
* description: User's full name
|
|
|
|
|
|
|
| 107 |
* responses:
|
| 108 |
* 200:
|
| 109 |
* description: Profile updated successfully
|
|
|
|
| 114 |
* 500:
|
| 115 |
* description: Server error
|
| 116 |
*/
|
| 117 |
+
router.put("/", authenticateToken, async (req: AuthRequest, res) => {
|
| 118 |
try {
|
| 119 |
const userId = req.userId!;
|
| 120 |
const { name } = req.body;
|
|
|
|
| 121 |
|
| 122 |
const userRepo = AppDataSource.getRepository(User);
|
| 123 |
const user = await userRepo.findOne({ where: { id: userId } });
|
|
|
|
| 134 |
user.name = name.trim();
|
| 135 |
}
|
| 136 |
|
| 137 |
+
// Profile picture is automatically generated from avatar render
|
| 138 |
+
// Update profilePicture with avatar render if avatar exists
|
| 139 |
+
if (user.readyPlayerMeAvatarId) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
try {
|
| 141 |
+
const avatarRenderUrl = await readyPlayerMeClient.getAvatar2DRender(user.readyPlayerMeAvatarId, {
|
| 142 |
+
size: 400,
|
| 143 |
+
quality: 90,
|
| 144 |
+
camera: "portrait",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
});
|
| 146 |
+
user.profilePicture = avatarRenderUrl;
|
| 147 |
+
} catch (error: any) {
|
| 148 |
+
console.warn("Failed to update profile picture from avatar:", error.message);
|
| 149 |
}
|
| 150 |
}
|
| 151 |
|
|
|
|
| 158 |
name: user.name,
|
| 159 |
email: user.email,
|
| 160 |
profilePicture: user.profilePicture || undefined,
|
| 161 |
+
readyPlayerMeAvatarId: user.readyPlayerMeAvatarId || undefined,
|
| 162 |
+
readyPlayerMeUserId: user.readyPlayerMeUserId || undefined,
|
| 163 |
createdAt: user.createdAt,
|
| 164 |
},
|
| 165 |
});
|
src/routes/suggest.ts
CHANGED
|
@@ -2,8 +2,10 @@ import express from "express";
|
|
| 2 |
import axios from "axios";
|
| 3 |
import { AppDataSource } from "../utils/dataSource";
|
| 4 |
import { WardrobeItem } from "../entity/WardrobeItem";
|
|
|
|
| 5 |
import { authenticateToken, AuthRequest } from "../middleware/auth";
|
| 6 |
import { normalizeCategory } from "../utils/categoryNormalizer";
|
|
|
|
| 7 |
import dotenv from "dotenv";
|
| 8 |
dotenv.config();
|
| 9 |
|
|
@@ -84,6 +86,44 @@ function extractSelectedItems(aiResponse: string, wardrobe: WardrobeItem[]): War
|
|
| 84 |
return selectedItems.slice(0, 6);
|
| 85 |
}
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
router.post("/", authenticateToken, async (req: AuthRequest, res) => {
|
| 88 |
try {
|
| 89 |
const { message, session_id, images } = req.body;
|
|
@@ -254,15 +294,56 @@ router.post("/stream", authenticateToken, async (req: AuthRequest, res) => {
|
|
| 254 |
}
|
| 255 |
});
|
| 256 |
|
| 257 |
-
response.data.on('end', () => {
|
| 258 |
console.log(`[Suggest Stream] Stream complete, response length: ${fullResponse.length}`);
|
| 259 |
|
| 260 |
// Extract selected items from the full response
|
| 261 |
const selectedItems = extractSelectedItems(fullResponse, wardrobe);
|
| 262 |
console.log(`[Suggest Stream] Extracted ${selectedItems.length} items`);
|
| 263 |
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
res.end();
|
| 267 |
});
|
| 268 |
|
|
|
|
| 2 |
import axios from "axios";
|
| 3 |
import { AppDataSource } from "../utils/dataSource";
|
| 4 |
import { WardrobeItem } from "../entity/WardrobeItem";
|
| 5 |
+
import { User } from "../entity/User";
|
| 6 |
import { authenticateToken, AuthRequest } from "../middleware/auth";
|
| 7 |
import { normalizeCategory } from "../utils/categoryNormalizer";
|
| 8 |
+
import { readyPlayerMeClient } from "../utils/readyPlayerMe";
|
| 9 |
import dotenv from "dotenv";
|
| 10 |
dotenv.config();
|
| 11 |
|
|
|
|
| 86 |
return selectedItems.slice(0, 6);
|
| 87 |
}
|
| 88 |
|
| 89 |
+
/**
|
| 90 |
+
* @openapi
|
| 91 |
+
* /api/suggest:
|
| 92 |
+
* post:
|
| 93 |
+
* summary: Get AI-powered outfit suggestion (non-streaming)
|
| 94 |
+
* tags: [Suggest]
|
| 95 |
+
* security:
|
| 96 |
+
* - bearerAuth: []
|
| 97 |
+
* requestBody:
|
| 98 |
+
* required: true
|
| 99 |
+
* content:
|
| 100 |
+
* application/json:
|
| 101 |
+
* schema:
|
| 102 |
+
* type: object
|
| 103 |
+
* required:
|
| 104 |
+
* - message
|
| 105 |
+
* properties:
|
| 106 |
+
* message:
|
| 107 |
+
* type: string
|
| 108 |
+
* description: User's outfit request message
|
| 109 |
+
* session_id:
|
| 110 |
+
* type: string
|
| 111 |
+
* description: Optional session ID for conversation context
|
| 112 |
+
* images:
|
| 113 |
+
* type: array
|
| 114 |
+
* items:
|
| 115 |
+
* type: string
|
| 116 |
+
* description: Optional array of image URLs
|
| 117 |
+
* responses:
|
| 118 |
+
* 200:
|
| 119 |
+
* description: Outfit suggestion generated successfully
|
| 120 |
+
* 400:
|
| 121 |
+
* description: Bad request
|
| 122 |
+
* 401:
|
| 123 |
+
* description: Unauthorized
|
| 124 |
+
* 500:
|
| 125 |
+
* description: Server error
|
| 126 |
+
*/
|
| 127 |
router.post("/", authenticateToken, async (req: AuthRequest, res) => {
|
| 128 |
try {
|
| 129 |
const { message, session_id, images } = req.body;
|
|
|
|
| 294 |
}
|
| 295 |
});
|
| 296 |
|
| 297 |
+
response.data.on('end', async () => {
|
| 298 |
console.log(`[Suggest Stream] Stream complete, response length: ${fullResponse.length}`);
|
| 299 |
|
| 300 |
// Extract selected items from the full response
|
| 301 |
const selectedItems = extractSelectedItems(fullResponse, wardrobe);
|
| 302 |
console.log(`[Suggest Stream] Extracted ${selectedItems.length} items`);
|
| 303 |
|
| 304 |
+
let avatarWithOutfitUrl: string | null = null;
|
| 305 |
+
|
| 306 |
+
// Equip assets to avatar if user has avatar and items have asset IDs
|
| 307 |
+
if (selectedItems.length > 0) {
|
| 308 |
+
try {
|
| 309 |
+
const userRepo = AppDataSource.getRepository(User);
|
| 310 |
+
const user = await userRepo.findOne({ where: { id: userId } });
|
| 311 |
+
|
| 312 |
+
if (user && user.readyPlayerMeAvatarId) {
|
| 313 |
+
const assetIds = selectedItems
|
| 314 |
+
.map(item => item.readyPlayerMeAssetId)
|
| 315 |
+
.filter(id => id !== null && id !== undefined) as string[];
|
| 316 |
+
|
| 317 |
+
if (assetIds.length > 0) {
|
| 318 |
+
// Equip all assets to avatar
|
| 319 |
+
const equipPromises = assetIds.map(assetId =>
|
| 320 |
+
readyPlayerMeClient.equipAsset(user.readyPlayerMeAvatarId!, assetId)
|
| 321 |
+
);
|
| 322 |
+
|
| 323 |
+
await Promise.all(equipPromises);
|
| 324 |
+
|
| 325 |
+
// Get avatar GLB URL with equipped outfit
|
| 326 |
+
avatarWithOutfitUrl = await readyPlayerMeClient.getAvatarGLB(user.readyPlayerMeAvatarId, {
|
| 327 |
+
quality: "medium",
|
| 328 |
+
lod: 1,
|
| 329 |
+
textureFormat: "webp",
|
| 330 |
+
useDracoMeshCompression: true,
|
| 331 |
+
});
|
| 332 |
+
|
| 333 |
+
console.log(`[Suggest Stream] Equipped ${assetIds.length} assets to avatar`);
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
} catch (outfitError: any) {
|
| 337 |
+
console.warn(`[Suggest Stream] Failed to equip outfit:`, outfitError.message);
|
| 338 |
+
}
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
// Send final message with selected items and avatar URL
|
| 342 |
+
res.write(`data: ${JSON.stringify({
|
| 343 |
+
type: "done",
|
| 344 |
+
selectedItems: selectedItems,
|
| 345 |
+
avatarWithOutfitUrl: avatarWithOutfitUrl
|
| 346 |
+
})}\n\n`);
|
| 347 |
res.end();
|
| 348 |
});
|
| 349 |
|
src/routes/upload.ts
CHANGED
|
@@ -6,6 +6,9 @@ import { AppDataSource } from "../utils/dataSource";
|
|
| 6 |
import { WardrobeItem } from "../entity/WardrobeItem";
|
| 7 |
import { authenticateToken, AuthRequest } from "../middleware/auth";
|
| 8 |
import { normalizeCategory } from "../utils/categoryNormalizer";
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
const router = express.Router();
|
| 11 |
|
|
@@ -137,11 +140,40 @@ router.post("/", authenticateToken, upload.array("image", 20), async (req: AuthR
|
|
| 137 |
|
| 138 |
console.log(`✅ Image converted to base64: ${itemName}`);
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
const style = selectedStyle;
|
| 141 |
|
| 142 |
const newItem = itemRepo.create({
|
| 143 |
imageUrl: base64Image,
|
| 144 |
processedImageUrl: processedBase64Image,
|
|
|
|
| 145 |
category: normalizedCategory,
|
| 146 |
style: style,
|
| 147 |
name: itemName,
|
|
@@ -151,8 +183,52 @@ router.post("/", authenticateToken, upload.array("image", 20), async (req: AuthR
|
|
| 151 |
});
|
| 152 |
|
| 153 |
const savedItem = await itemRepo.save(newItem);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
uploadedItems.push(savedItem);
|
| 155 |
-
console.log(
|
| 156 |
} catch (error: any) {
|
| 157 |
console.error(`❌ Error processing file ${file.originalname}:`, error);
|
| 158 |
failedFiles.push(file.originalname);
|
|
|
|
| 6 |
import { WardrobeItem } from "../entity/WardrobeItem";
|
| 7 |
import { authenticateToken, AuthRequest } from "../middleware/auth";
|
| 8 |
import { normalizeCategory } from "../utils/categoryNormalizer";
|
| 9 |
+
import { generate3DModel, convert3DModelToBase64 } from "../utils/tencent3D";
|
| 10 |
+
import { readyPlayerMeClient } from "../utils/readyPlayerMe";
|
| 11 |
+
import { mapCategoryToAssetType, getAssetGender } from "../utils/assetTypeMapper";
|
| 12 |
|
| 13 |
const router = express.Router();
|
| 14 |
|
|
|
|
| 140 |
|
| 141 |
console.log(`✅ Image converted to base64: ${itemName}`);
|
| 142 |
|
| 143 |
+
let model3dUrl: string | undefined = undefined;
|
| 144 |
+
|
| 145 |
+
try {
|
| 146 |
+
console.log(`🔄 Generating 3D model for: ${file.originalname}`);
|
| 147 |
+
const imageFor3D = processedBuffer || file.buffer;
|
| 148 |
+
const mimeTypeFor3D = processedBuffer ? "image/png" : file.mimetype;
|
| 149 |
+
|
| 150 |
+
const threeDResult = await generate3DModel(
|
| 151 |
+
imageFor3D,
|
| 152 |
+
file.originalname,
|
| 153 |
+
mimeTypeFor3D
|
| 154 |
+
);
|
| 155 |
+
|
| 156 |
+
if (threeDResult && threeDResult.modelFile) {
|
| 157 |
+
const base64Model = await convert3DModelToBase64(threeDResult.modelFile);
|
| 158 |
+
if (base64Model) {
|
| 159 |
+
model3dUrl = base64Model;
|
| 160 |
+
console.log(`✅ 3D model generated and converted to base64 for: ${file.originalname}`);
|
| 161 |
+
} else {
|
| 162 |
+
console.warn(`⚠️ Failed to convert 3D model to base64 for: ${file.originalname}`);
|
| 163 |
+
}
|
| 164 |
+
} else {
|
| 165 |
+
console.warn(`⚠️ 3D model generation returned no file for: ${file.originalname}`);
|
| 166 |
+
}
|
| 167 |
+
} catch (threeDError: any) {
|
| 168 |
+
console.warn(`⚠️ 3D model generation failed for ${file.originalname}:`, threeDError.message);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
const style = selectedStyle;
|
| 172 |
|
| 173 |
const newItem = itemRepo.create({
|
| 174 |
imageUrl: base64Image,
|
| 175 |
processedImageUrl: processedBase64Image,
|
| 176 |
+
model3dUrl: model3dUrl,
|
| 177 |
category: normalizedCategory,
|
| 178 |
style: style,
|
| 179 |
name: itemName,
|
|
|
|
| 183 |
});
|
| 184 |
|
| 185 |
const savedItem = await itemRepo.save(newItem);
|
| 186 |
+
|
| 187 |
+
if (model3dUrl && process.env.READY_PLAYER_ME_APPLICATION_ID && process.env.READY_PLAYER_ME_ORGANIZATION_ID) {
|
| 188 |
+
try {
|
| 189 |
+
const assetType = mapCategoryToAssetType(normalizedCategory);
|
| 190 |
+
if (assetType) {
|
| 191 |
+
const base64Data = model3dUrl.split(',')[1];
|
| 192 |
+
const binaryString = Buffer.from(base64Data, 'base64');
|
| 193 |
+
|
| 194 |
+
const modelUrl = await readyPlayerMeClient.uploadAssetFile(
|
| 195 |
+
binaryString,
|
| 196 |
+
`${itemName.replace(/\s+/g, '-')}.glb`,
|
| 197 |
+
"model/gltf-binary"
|
| 198 |
+
);
|
| 199 |
+
|
| 200 |
+
if (modelUrl) {
|
| 201 |
+
const iconUrl = processedBase64Image || base64Image;
|
| 202 |
+
|
| 203 |
+
const asset = await readyPlayerMeClient.createAsset({
|
| 204 |
+
name: itemName,
|
| 205 |
+
type: assetType,
|
| 206 |
+
gender: getAssetGender(),
|
| 207 |
+
modelUrl: modelUrl,
|
| 208 |
+
iconUrl: iconUrl,
|
| 209 |
+
organizationId: process.env.READY_PLAYER_ME_ORGANIZATION_ID!,
|
| 210 |
+
locked: false,
|
| 211 |
+
applications: [{
|
| 212 |
+
id: process.env.READY_PLAYER_ME_APPLICATION_ID!,
|
| 213 |
+
organizationId: process.env.READY_PLAYER_ME_ORGANIZATION_ID!,
|
| 214 |
+
isVisibleInEditor: true,
|
| 215 |
+
}],
|
| 216 |
+
});
|
| 217 |
+
|
| 218 |
+
if (asset) {
|
| 219 |
+
savedItem.readyPlayerMeAssetId = asset.id;
|
| 220 |
+
await itemRepo.save(savedItem);
|
| 221 |
+
console.log(`Created Ready Player Me asset: ${asset.id} for item ${savedItem.id}`);
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
} catch (assetError: any) {
|
| 226 |
+
console.warn(`Failed to create Ready Player Me asset for ${file.originalname}:`, assetError.message);
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
uploadedItems.push(savedItem);
|
| 231 |
+
console.log(`Successfully processed: ${file.originalname} -> ${itemName} (${normalizedCategory})`);
|
| 232 |
} catch (error: any) {
|
| 233 |
console.error(`❌ Error processing file ${file.originalname}:`, error);
|
| 234 |
failedFiles.push(file.originalname);
|
src/utils/assetTypeMapper.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { normalizeCategory } from "./categoryNormalizer";
|
| 2 |
+
|
| 3 |
+
export type ReadyPlayerMeAssetType =
|
| 4 |
+
| "top"
|
| 5 |
+
| "bottom"
|
| 6 |
+
| "footwear"
|
| 7 |
+
| "glasses"
|
| 8 |
+
| "headwear"
|
| 9 |
+
| "outfit"
|
| 10 |
+
| "beard"
|
| 11 |
+
| "eye"
|
| 12 |
+
| "eyebrows"
|
| 13 |
+
| "eyeshape"
|
| 14 |
+
| "facemask"
|
| 15 |
+
| "faceshape"
|
| 16 |
+
| "facewear"
|
| 17 |
+
| "hair"
|
| 18 |
+
| "lipshape"
|
| 19 |
+
| "noseshape"
|
| 20 |
+
| "shirt"
|
| 21 |
+
| "costume";
|
| 22 |
+
|
| 23 |
+
export function mapCategoryToAssetType(category: string): ReadyPlayerMeAssetType | null {
|
| 24 |
+
const normalized = normalizeCategory(category.toLowerCase());
|
| 25 |
+
|
| 26 |
+
const categoryMap: Record<string, ReadyPlayerMeAssetType> = {
|
| 27 |
+
shirts: "top",
|
| 28 |
+
tops: "top",
|
| 29 |
+
shirt: "top",
|
| 30 |
+
tshirt: "top",
|
| 31 |
+
blouse: "top",
|
| 32 |
+
polo: "top",
|
| 33 |
+
sweater: "top",
|
| 34 |
+
hoodie: "top",
|
| 35 |
+
jacket: "top",
|
| 36 |
+
coat: "top",
|
| 37 |
+
blazer: "top",
|
| 38 |
+
pants: "bottom",
|
| 39 |
+
trousers: "bottom",
|
| 40 |
+
jeans: "bottom",
|
| 41 |
+
shorts: "bottom",
|
| 42 |
+
skirt: "bottom",
|
| 43 |
+
leggings: "bottom",
|
| 44 |
+
shoes: "footwear",
|
| 45 |
+
sneakers: "footwear",
|
| 46 |
+
boots: "footwear",
|
| 47 |
+
heels: "footwear",
|
| 48 |
+
sandals: "footwear",
|
| 49 |
+
loafers: "footwear",
|
| 50 |
+
glasses: "glasses",
|
| 51 |
+
sunglasses: "glasses",
|
| 52 |
+
hats: "headwear",
|
| 53 |
+
hat: "headwear",
|
| 54 |
+
cap: "headwear",
|
| 55 |
+
beanie: "headwear",
|
| 56 |
+
helmet: "headwear",
|
| 57 |
+
dresses: "outfit",
|
| 58 |
+
dress: "outfit",
|
| 59 |
+
jumpsuit: "outfit",
|
| 60 |
+
romper: "outfit",
|
| 61 |
+
suits: "outfit",
|
| 62 |
+
suit: "outfit",
|
| 63 |
+
watches: "facewear",
|
| 64 |
+
watch: "facewear",
|
| 65 |
+
bags: "facewear",
|
| 66 |
+
bag: "facewear",
|
| 67 |
+
backpack: "facewear",
|
| 68 |
+
purse: "facewear",
|
| 69 |
+
jewelry: "facewear",
|
| 70 |
+
necklace: "facewear",
|
| 71 |
+
bracelet: "facewear",
|
| 72 |
+
ring: "facewear",
|
| 73 |
+
earring: "facewear",
|
| 74 |
+
belts: "facewear",
|
| 75 |
+
belt: "facewear",
|
| 76 |
+
scarves: "facewear",
|
| 77 |
+
scarf: "facewear",
|
| 78 |
+
ties: "facewear",
|
| 79 |
+
tie: "facewear",
|
| 80 |
+
gloves: "facewear",
|
| 81 |
+
glove: "facewear",
|
| 82 |
+
swimwear: "outfit",
|
| 83 |
+
bikini: "outfit",
|
| 84 |
+
swimsuit: "outfit",
|
| 85 |
+
underwear: "outfit",
|
| 86 |
+
bra: "outfit",
|
| 87 |
+
brief: "outfit",
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
return categoryMap[normalized] || null;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
export function getAssetGender(): "male" | "female" | "neutral" {
|
| 94 |
+
return "neutral";
|
| 95 |
+
}
|
| 96 |
+
|
src/utils/readyPlayerMe.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from "axios";
|
| 2 |
+
import dotenv from "dotenv";
|
| 3 |
+
import FormData from "form-data";
|
| 4 |
+
|
| 5 |
+
dotenv.config();
|
| 6 |
+
|
| 7 |
+
const READY_API_KEY = process.env.READY_API_KEY;
|
| 8 |
+
const READY_API_BASE = "https://api.readyplayer.me/v1";
|
| 9 |
+
const READY_MODELS_BASE = "https://models.readyplayer.me";
|
| 10 |
+
const READY_AVATARS_BASE = "https://avatars.readyplayer.me";
|
| 11 |
+
|
| 12 |
+
if (!READY_API_KEY) {
|
| 13 |
+
console.warn("READY_API_KEY not found in environment variables");
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const getHeaders = () => ({
|
| 17 |
+
"X-API-Key": READY_API_KEY || "",
|
| 18 |
+
"Content-Type": "application/json",
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
export interface ReadyPlayerMeUser {
|
| 22 |
+
id: string;
|
| 23 |
+
applicationIds: string[];
|
| 24 |
+
partners: string[];
|
| 25 |
+
createdAt: string;
|
| 26 |
+
updatedAt: string;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export interface ReadyPlayerMeAsset {
|
| 30 |
+
id: string;
|
| 31 |
+
name: string;
|
| 32 |
+
type: string;
|
| 33 |
+
gender: string;
|
| 34 |
+
modelUrl: string;
|
| 35 |
+
iconUrl: string;
|
| 36 |
+
organizationId: string;
|
| 37 |
+
locked: boolean;
|
| 38 |
+
applications?: any[];
|
| 39 |
+
hasApps: boolean;
|
| 40 |
+
createdAt: string;
|
| 41 |
+
updatedAt: string;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export interface ReadyPlayerMeToken {
|
| 45 |
+
token: string;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export interface ReadyPlayerMeAvatarMetadata {
|
| 49 |
+
id: string;
|
| 50 |
+
url?: string;
|
| 51 |
+
[key: string]: any;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export class ReadyPlayerMeClient {
|
| 55 |
+
private apiKey: string;
|
| 56 |
+
private applicationId?: string;
|
| 57 |
+
|
| 58 |
+
constructor(applicationId?: string) {
|
| 59 |
+
this.apiKey = READY_API_KEY || "";
|
| 60 |
+
this.applicationId = applicationId;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
async createGuestUser(applicationId: string): Promise<ReadyPlayerMeUser | null> {
|
| 64 |
+
try {
|
| 65 |
+
const response = await axios.post<{ data: ReadyPlayerMeUser }>(
|
| 66 |
+
`${READY_API_BASE}/users`,
|
| 67 |
+
{
|
| 68 |
+
data: {
|
| 69 |
+
applicationId,
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
headers: getHeaders(),
|
| 74 |
+
}
|
| 75 |
+
);
|
| 76 |
+
return response.data.data;
|
| 77 |
+
} catch (error: any) {
|
| 78 |
+
console.error("Error creating Ready Player Me guest user:", error.response?.data || error.message);
|
| 79 |
+
return null;
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
async getAuthToken(userId: string, partner: string): Promise<string | null> {
|
| 84 |
+
try {
|
| 85 |
+
const response = await axios.get<{ data: ReadyPlayerMeToken }>(
|
| 86 |
+
`${READY_API_BASE}/auth/token`,
|
| 87 |
+
{
|
| 88 |
+
params: { userId, partner },
|
| 89 |
+
headers: getHeaders(),
|
| 90 |
+
}
|
| 91 |
+
);
|
| 92 |
+
return response.data.data.token;
|
| 93 |
+
} catch (error: any) {
|
| 94 |
+
console.error("Error getting Ready Player Me token:", error.response?.data || error.message);
|
| 95 |
+
return null;
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
async getAvatarGLB(avatarId: string, options?: {
|
| 100 |
+
quality?: "low" | "medium" | "high" | "ultra";
|
| 101 |
+
lod?: number;
|
| 102 |
+
textureAtlas?: number | "none";
|
| 103 |
+
textureFormat?: "webp" | "jpeg" | "png";
|
| 104 |
+
useDracoMeshCompression?: boolean;
|
| 105 |
+
}): Promise<string> {
|
| 106 |
+
const params = new URLSearchParams();
|
| 107 |
+
if (options?.quality) params.append("quality", options.quality);
|
| 108 |
+
if (options?.lod !== undefined) params.append("lod", options.lod.toString());
|
| 109 |
+
if (options?.textureAtlas) params.append("textureAtlas", options.textureAtlas.toString());
|
| 110 |
+
if (options?.textureFormat) params.append("textureFormat", options.textureFormat);
|
| 111 |
+
if (options?.useDracoMeshCompression) params.append("useDracoMeshCompression", "true");
|
| 112 |
+
|
| 113 |
+
const queryString = params.toString();
|
| 114 |
+
return `${READY_AVATARS_BASE}/${avatarId}.glb${queryString ? `?${queryString}` : ""}`;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
async getAvatar2DRender(avatarId: string, options?: {
|
| 118 |
+
size?: number;
|
| 119 |
+
quality?: number;
|
| 120 |
+
camera?: "portrait" | "fullbody" | "fit";
|
| 121 |
+
background?: string;
|
| 122 |
+
expression?: string;
|
| 123 |
+
pose?: string;
|
| 124 |
+
}): Promise<string> {
|
| 125 |
+
const params = new URLSearchParams();
|
| 126 |
+
if (options?.size) params.append("size", options.size.toString());
|
| 127 |
+
if (options?.quality) params.append("quality", options.quality.toString());
|
| 128 |
+
if (options?.camera) params.append("camera", options.camera);
|
| 129 |
+
if (options?.background) params.append("background", options.background);
|
| 130 |
+
if (options?.expression) params.append("expression", options.expression);
|
| 131 |
+
if (options?.pose) params.append("pose", options.pose);
|
| 132 |
+
|
| 133 |
+
const queryString = params.toString();
|
| 134 |
+
return `${READY_MODELS_BASE}/${avatarId}.png${queryString ? `?${queryString}` : ""}`;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
async getAvatarMetadata(avatarId: string): Promise<ReadyPlayerMeAvatarMetadata | null> {
|
| 138 |
+
try {
|
| 139 |
+
const response = await axios.get<{ data: ReadyPlayerMeAvatarMetadata }>(
|
| 140 |
+
`${READY_MODELS_BASE}/${avatarId}.json`,
|
| 141 |
+
{
|
| 142 |
+
headers: getHeaders(),
|
| 143 |
+
}
|
| 144 |
+
);
|
| 145 |
+
return response.data.data;
|
| 146 |
+
} catch (error: any) {
|
| 147 |
+
console.error("Error getting avatar metadata:", error.response?.data || error.message);
|
| 148 |
+
return null;
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
async uploadAssetFile(fileBuffer: Buffer, filename: string, mimetype: string): Promise<string | null> {
|
| 153 |
+
try {
|
| 154 |
+
const formData = new FormData();
|
| 155 |
+
formData.append("file", fileBuffer, {
|
| 156 |
+
filename,
|
| 157 |
+
contentType: mimetype,
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
const response = await axios.post<{ data: { url: string } }>(
|
| 161 |
+
`${READY_API_BASE}/temporary-media`,
|
| 162 |
+
formData,
|
| 163 |
+
{
|
| 164 |
+
headers: {
|
| 165 |
+
...getHeaders(),
|
| 166 |
+
...formData.getHeaders(),
|
| 167 |
+
},
|
| 168 |
+
}
|
| 169 |
+
);
|
| 170 |
+
return response.data.data.url;
|
| 171 |
+
} catch (error: any) {
|
| 172 |
+
console.error("Error uploading asset file:", error.response?.data || error.message);
|
| 173 |
+
return null;
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
async createAsset(data: {
|
| 178 |
+
name: string;
|
| 179 |
+
type: string;
|
| 180 |
+
gender: "male" | "female" | "neutral";
|
| 181 |
+
modelUrl: string;
|
| 182 |
+
iconUrl: string;
|
| 183 |
+
organizationId: string;
|
| 184 |
+
locked?: boolean;
|
| 185 |
+
applications?: Array<{
|
| 186 |
+
id: string;
|
| 187 |
+
organizationId: string;
|
| 188 |
+
isVisibleInEditor?: boolean;
|
| 189 |
+
}>;
|
| 190 |
+
}): Promise<ReadyPlayerMeAsset | null> {
|
| 191 |
+
try {
|
| 192 |
+
const response = await axios.post<{ data: ReadyPlayerMeAsset }>(
|
| 193 |
+
`${READY_API_BASE}/assets`,
|
| 194 |
+
{ data },
|
| 195 |
+
{
|
| 196 |
+
headers: getHeaders(),
|
| 197 |
+
}
|
| 198 |
+
);
|
| 199 |
+
return response.data.data;
|
| 200 |
+
} catch (error: any) {
|
| 201 |
+
console.error("Error creating asset:", error.response?.data || error.message);
|
| 202 |
+
return null;
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
async listAssets(filters?: {
|
| 207 |
+
type?: string[];
|
| 208 |
+
gender?: string[];
|
| 209 |
+
name?: string;
|
| 210 |
+
organizationId?: string;
|
| 211 |
+
applicationIds?: string[];
|
| 212 |
+
limit?: number;
|
| 213 |
+
page?: number;
|
| 214 |
+
}): Promise<{ data: ReadyPlayerMeAsset[]; pagination: any } | null> {
|
| 215 |
+
try {
|
| 216 |
+
const params = new URLSearchParams();
|
| 217 |
+
if (filters?.type) {
|
| 218 |
+
filters.type.forEach(t => params.append("type", t));
|
| 219 |
+
}
|
| 220 |
+
if (filters?.gender) {
|
| 221 |
+
filters.gender.forEach(g => params.append("gender", g));
|
| 222 |
+
}
|
| 223 |
+
if (filters?.name) params.append("name", filters.name);
|
| 224 |
+
if (filters?.organizationId) params.append("organizationId", filters.organizationId);
|
| 225 |
+
if (filters?.applicationIds) {
|
| 226 |
+
filters.applicationIds.forEach(id => params.append("applicationIds", id));
|
| 227 |
+
}
|
| 228 |
+
if (filters?.limit) params.append("limit", filters.limit.toString());
|
| 229 |
+
if (filters?.page) params.append("page", filters.page.toString());
|
| 230 |
+
|
| 231 |
+
const headers = { ...getHeaders() };
|
| 232 |
+
if (this.applicationId) {
|
| 233 |
+
headers["X-APP-ID"] = this.applicationId;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
const response = await axios.get<{ data: ReadyPlayerMeAsset[]; pagination: any }>(
|
| 237 |
+
`${READY_API_BASE}/assets?${params.toString()}`,
|
| 238 |
+
{ headers }
|
| 239 |
+
);
|
| 240 |
+
return response.data;
|
| 241 |
+
} catch (error: any) {
|
| 242 |
+
console.error("Error listing assets:", error.response?.data || error.message);
|
| 243 |
+
return null;
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
async updateAsset(assetId: string, data: {
|
| 248 |
+
name?: string;
|
| 249 |
+
type?: string;
|
| 250 |
+
gender?: "male" | "female" | "neutral";
|
| 251 |
+
modelUrl?: string;
|
| 252 |
+
iconUrl?: string;
|
| 253 |
+
locked?: boolean;
|
| 254 |
+
applications?: Array<{
|
| 255 |
+
id: string;
|
| 256 |
+
organizationId: string;
|
| 257 |
+
isVisibleInEditor?: boolean;
|
| 258 |
+
}>;
|
| 259 |
+
}): Promise<ReadyPlayerMeAsset | null> {
|
| 260 |
+
try {
|
| 261 |
+
const response = await axios.patch<{ data: ReadyPlayerMeAsset }>(
|
| 262 |
+
`${READY_API_BASE}/assets/${assetId}`,
|
| 263 |
+
{ data },
|
| 264 |
+
{
|
| 265 |
+
headers: getHeaders(),
|
| 266 |
+
}
|
| 267 |
+
);
|
| 268 |
+
return response.data.data;
|
| 269 |
+
} catch (error: any) {
|
| 270 |
+
console.error("Error updating asset:", error.response?.data || error.message);
|
| 271 |
+
return null;
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
async equipAsset(avatarId: string, assetId: string): Promise<boolean> {
|
| 276 |
+
try {
|
| 277 |
+
await axios.put(
|
| 278 |
+
`${READY_API_BASE}/avatars/${avatarId}/equip`,
|
| 279 |
+
{
|
| 280 |
+
data: {
|
| 281 |
+
assetId,
|
| 282 |
+
},
|
| 283 |
+
},
|
| 284 |
+
{
|
| 285 |
+
headers: getHeaders(),
|
| 286 |
+
}
|
| 287 |
+
);
|
| 288 |
+
return true;
|
| 289 |
+
} catch (error: any) {
|
| 290 |
+
console.error("Error equipping asset:", error.response?.data || error.message);
|
| 291 |
+
return false;
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
async unequipAsset(avatarId: string, assetId: string): Promise<boolean> {
|
| 296 |
+
try {
|
| 297 |
+
await axios.put(
|
| 298 |
+
`${READY_API_BASE}/avatars/${avatarId}/unequip`,
|
| 299 |
+
{
|
| 300 |
+
data: {
|
| 301 |
+
assetId,
|
| 302 |
+
},
|
| 303 |
+
},
|
| 304 |
+
{
|
| 305 |
+
headers: getHeaders(),
|
| 306 |
+
}
|
| 307 |
+
);
|
| 308 |
+
return true;
|
| 309 |
+
} catch (error: any) {
|
| 310 |
+
console.error("Error unequipping asset:", error.response?.data || error.message);
|
| 311 |
+
return false;
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
async addAssetToApplication(assetId: string, applicationId: string, isVisibleInEditor: boolean = true): Promise<boolean> {
|
| 316 |
+
try {
|
| 317 |
+
await axios.post(
|
| 318 |
+
`${READY_API_BASE}/assets/${assetId}/application`,
|
| 319 |
+
{
|
| 320 |
+
data: {
|
| 321 |
+
applicationId,
|
| 322 |
+
isVisibleInEditor,
|
| 323 |
+
},
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
headers: getHeaders(),
|
| 327 |
+
}
|
| 328 |
+
);
|
| 329 |
+
return true;
|
| 330 |
+
} catch (error: any) {
|
| 331 |
+
console.error("Error adding asset to application:", error.response?.data || error.message);
|
| 332 |
+
return false;
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
async removeAssetFromApplication(assetId: string, applicationId: string): Promise<boolean> {
|
| 337 |
+
try {
|
| 338 |
+
await axios.delete(
|
| 339 |
+
`${READY_API_BASE}/assets/${assetId}/application`,
|
| 340 |
+
{
|
| 341 |
+
data: {
|
| 342 |
+
applicationId,
|
| 343 |
+
},
|
| 344 |
+
headers: getHeaders(),
|
| 345 |
+
}
|
| 346 |
+
);
|
| 347 |
+
return true;
|
| 348 |
+
} catch (error: any) {
|
| 349 |
+
console.error("Error removing asset from application:", error.response?.data || error.message);
|
| 350 |
+
return false;
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
async unlockAssetForUser(assetId: string, userId: string): Promise<boolean> {
|
| 355 |
+
try {
|
| 356 |
+
await axios.put(
|
| 357 |
+
`${READY_API_BASE}/assets/${assetId}/unlock`,
|
| 358 |
+
{
|
| 359 |
+
data: {
|
| 360 |
+
userId,
|
| 361 |
+
},
|
| 362 |
+
},
|
| 363 |
+
{
|
| 364 |
+
headers: getHeaders(),
|
| 365 |
+
}
|
| 366 |
+
);
|
| 367 |
+
return true;
|
| 368 |
+
} catch (error: any) {
|
| 369 |
+
console.error("Error unlocking asset:", error.response?.data || error.message);
|
| 370 |
+
return false;
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
async lockAssetForUser(assetId: string, userId: string): Promise<boolean> {
|
| 375 |
+
try {
|
| 376 |
+
await axios.put(
|
| 377 |
+
`${READY_API_BASE}/assets/${assetId}/lock`,
|
| 378 |
+
{
|
| 379 |
+
data: {
|
| 380 |
+
userId,
|
| 381 |
+
},
|
| 382 |
+
},
|
| 383 |
+
{
|
| 384 |
+
headers: getHeaders(),
|
| 385 |
+
}
|
| 386 |
+
);
|
| 387 |
+
return true;
|
| 388 |
+
} catch (error: any) {
|
| 389 |
+
console.error("Error locking asset:", error.response?.data || error.message);
|
| 390 |
+
return false;
|
| 391 |
+
}
|
| 392 |
+
}
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
export const readyPlayerMeClient = new ReadyPlayerMeClient();
|
| 396 |
+
|
src/utils/tencent3D.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Client } from "@gradio/client";
|
| 2 |
+
import dotenv from "dotenv";
|
| 3 |
+
|
| 4 |
+
dotenv.config();
|
| 5 |
+
|
| 6 |
+
const TENCENT_3D_SPACE = "tencent/Hunyuan3D-2.1";
|
| 7 |
+
const HF_TOKEN = process.env.HF_TOKEN || process.env.hface;
|
| 8 |
+
|
| 9 |
+
export interface Tencent3DResult {
|
| 10 |
+
modelFile: Blob | File | Buffer | null;
|
| 11 |
+
htmlOutput: string;
|
| 12 |
+
meshStats: any;
|
| 13 |
+
seed: number;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export async function generate3DModel(
|
| 17 |
+
imageBuffer: Buffer,
|
| 18 |
+
filename: string,
|
| 19 |
+
mimetype: string
|
| 20 |
+
): Promise<Tencent3DResult | null> {
|
| 21 |
+
try {
|
| 22 |
+
console.log(`Connecting to Tencent 3D API for: ${filename}`);
|
| 23 |
+
|
| 24 |
+
const connectOptions: any = {};
|
| 25 |
+
if (HF_TOKEN) {
|
| 26 |
+
connectOptions.hf_token = HF_TOKEN;
|
| 27 |
+
console.log(` Using HF_TOKEN for authentication`);
|
| 28 |
+
} else {
|
| 29 |
+
console.warn(` No HF_TOKEN found in environment variables (checked HF_TOKEN and hface)`);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const client = await Client.connect(TENCENT_3D_SPACE, connectOptions);
|
| 33 |
+
|
| 34 |
+
const imageBlob = new Blob([imageBuffer], { type: mimetype });
|
| 35 |
+
|
| 36 |
+
console.log(`Generating 3D model for: ${filename}`);
|
| 37 |
+
|
| 38 |
+
const result = await client.predict("/shape_generation", {
|
| 39 |
+
image: imageBlob,
|
| 40 |
+
mv_image_front: imageBlob,
|
| 41 |
+
mv_image_back: imageBlob,
|
| 42 |
+
mv_image_left: imageBlob,
|
| 43 |
+
mv_image_right: imageBlob,
|
| 44 |
+
steps: 30,
|
| 45 |
+
guidance_scale: 5,
|
| 46 |
+
seed: 1234,
|
| 47 |
+
octree_resolution: 256,
|
| 48 |
+
check_box_rembg: true,
|
| 49 |
+
num_chunks: 8000,
|
| 50 |
+
randomize_seed: true,
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
console.log(`3D model generated successfully for: ${filename}`);
|
| 54 |
+
|
| 55 |
+
const data = result.data as any[];
|
| 56 |
+
|
| 57 |
+
return {
|
| 58 |
+
modelFile: data[0] || null,
|
| 59 |
+
htmlOutput: data[1] || "",
|
| 60 |
+
meshStats: data[2] || null,
|
| 61 |
+
seed: data[3] || 1234,
|
| 62 |
+
};
|
| 63 |
+
} catch (error: any) {
|
| 64 |
+
console.error(`Error generating 3D model for ${filename}:`, error.message);
|
| 65 |
+
return null;
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export async function convert3DModelToBase64(
|
| 70 |
+
modelFile: Blob | File | Buffer | any
|
| 71 |
+
): Promise<string | null> {
|
| 72 |
+
try {
|
| 73 |
+
let buffer: Buffer;
|
| 74 |
+
|
| 75 |
+
if (modelFile instanceof Buffer) {
|
| 76 |
+
buffer = modelFile;
|
| 77 |
+
} else if (modelFile instanceof Blob) {
|
| 78 |
+
const arrayBuffer = await modelFile.arrayBuffer();
|
| 79 |
+
buffer = Buffer.from(arrayBuffer);
|
| 80 |
+
} else if (modelFile instanceof File) {
|
| 81 |
+
const arrayBuffer = await modelFile.arrayBuffer();
|
| 82 |
+
buffer = Buffer.from(arrayBuffer);
|
| 83 |
+
} else if (modelFile && typeof modelFile === 'object') {
|
| 84 |
+
// Handle Gradio client file objects
|
| 85 |
+
if (typeof modelFile.arrayBuffer === 'function') {
|
| 86 |
+
const arrayBuffer = await modelFile.arrayBuffer();
|
| 87 |
+
buffer = Buffer.from(arrayBuffer);
|
| 88 |
+
} else if (modelFile.value) {
|
| 89 |
+
// Gradio file component returns { value: url or object, __type__: "file" }
|
| 90 |
+
let fileUrl: string;
|
| 91 |
+
if (typeof modelFile.value === 'string') {
|
| 92 |
+
fileUrl = modelFile.value;
|
| 93 |
+
} else if (modelFile.value && typeof modelFile.value === 'object' && modelFile.value.url) {
|
| 94 |
+
fileUrl = modelFile.value.url;
|
| 95 |
+
} else if (modelFile.value && typeof modelFile.value === 'object' && modelFile.value.path) {
|
| 96 |
+
// Server-side file path
|
| 97 |
+
const fs = require('fs');
|
| 98 |
+
buffer = fs.readFileSync(modelFile.value.path);
|
| 99 |
+
const base64 = buffer.toString("base64");
|
| 100 |
+
return `data:model/gltf-binary;base64,${base64}`;
|
| 101 |
+
} else {
|
| 102 |
+
console.error("Unsupported file value format:", typeof modelFile.value, modelFile.value);
|
| 103 |
+
return null;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
try {
|
| 107 |
+
const response = await fetch(fileUrl);
|
| 108 |
+
if (!response.ok) {
|
| 109 |
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
| 110 |
+
}
|
| 111 |
+
const arrayBuffer = await response.arrayBuffer();
|
| 112 |
+
buffer = Buffer.from(arrayBuffer);
|
| 113 |
+
} catch (fetchError: any) {
|
| 114 |
+
console.error("Error fetching 3D model from URL:", fetchError.message);
|
| 115 |
+
return null;
|
| 116 |
+
}
|
| 117 |
+
} else if (modelFile.url) {
|
| 118 |
+
// Gradio returns file objects with URL - fetch the file
|
| 119 |
+
try {
|
| 120 |
+
const response = await fetch(modelFile.url);
|
| 121 |
+
const arrayBuffer = await response.arrayBuffer();
|
| 122 |
+
buffer = Buffer.from(arrayBuffer);
|
| 123 |
+
} catch (fetchError: any) {
|
| 124 |
+
console.error("Error fetching 3D model from URL:", fetchError.message);
|
| 125 |
+
return null;
|
| 126 |
+
}
|
| 127 |
+
} else if (modelFile.data) {
|
| 128 |
+
// Handle if it's wrapped in a data property
|
| 129 |
+
if (Buffer.isBuffer(modelFile.data)) {
|
| 130 |
+
buffer = modelFile.data;
|
| 131 |
+
} else if (typeof modelFile.data === 'string') {
|
| 132 |
+
// If it's already a base64 string or URL
|
| 133 |
+
if (modelFile.data.startsWith('data:')) {
|
| 134 |
+
return modelFile.data;
|
| 135 |
+
}
|
| 136 |
+
buffer = Buffer.from(modelFile.data, 'base64');
|
| 137 |
+
} else {
|
| 138 |
+
buffer = Buffer.from(modelFile.data);
|
| 139 |
+
}
|
| 140 |
+
} else if (modelFile.path) {
|
| 141 |
+
// If it's a file path (server-side), read it
|
| 142 |
+
const fs = require('fs');
|
| 143 |
+
buffer = fs.readFileSync(modelFile.path);
|
| 144 |
+
} else {
|
| 145 |
+
// Try to convert the object to buffer
|
| 146 |
+
try {
|
| 147 |
+
buffer = Buffer.from(modelFile);
|
| 148 |
+
} catch {
|
| 149 |
+
console.error("Unsupported file type for 3D model conversion:", typeof modelFile, modelFile.constructor?.name);
|
| 150 |
+
return null;
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
} else {
|
| 154 |
+
console.error("Unsupported file type for 3D model conversion:", typeof modelFile);
|
| 155 |
+
return null;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
const base64 = buffer.toString("base64");
|
| 159 |
+
return `data:model/gltf-binary;base64,${base64}`;
|
| 160 |
+
} catch (error: any) {
|
| 161 |
+
console.error("Error converting 3D model to base64:", error.message);
|
| 162 |
+
return null;
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
stylegptUI
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
Subproject commit
|
|
|
|
| 1 |
+
Subproject commit 5739926b88e5287f4f5d33e0bf8f4ec7f9b92e2c
|
tits.jpeg
ADDED
|