@xenterprises/fastify-ximagepipeline 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/FILES.md +0 -212
- package/IMPLEMENTATION_SUMMARY.md +0 -427
- package/INTEGRATION_GUIDE.md +0 -554
- package/test/xImagePipeline.test.js +0 -196
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xenterprises/fastify-ximagepipeline",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.1",
|
|
5
5
|
"description": "Fastify plugin for image uploads with EXIF stripping, moderation, variant generation, and R2 storage with job queue",
|
|
6
6
|
"main": "src/xImagePipeline.js",
|
|
7
7
|
"exports": {
|
package/FILES.md
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
# xMedia Plugin - File Listing
|
|
2
|
-
|
|
3
|
-
## Core Files
|
|
4
|
-
|
|
5
|
-
### Main Plugin
|
|
6
|
-
- **src/xMedia.js** (145 lines)
|
|
7
|
-
- Plugin initialization and configuration
|
|
8
|
-
- Route registration
|
|
9
|
-
- Worker setup
|
|
10
|
-
- Variant presets
|
|
11
|
-
|
|
12
|
-
### Services
|
|
13
|
-
- **src/services/s3.js** (168 lines)
|
|
14
|
-
- R2/S3 client initialization
|
|
15
|
-
- Upload, download, delete operations
|
|
16
|
-
- Public URL generation
|
|
17
|
-
- Batch operations
|
|
18
|
-
|
|
19
|
-
### Utilities
|
|
20
|
-
- **src/utils/image.js** (230 lines)
|
|
21
|
-
- EXIF stripping
|
|
22
|
-
- Image metadata extraction
|
|
23
|
-
- Variant generation
|
|
24
|
-
- Blurhash generation
|
|
25
|
-
- Image validation
|
|
26
|
-
|
|
27
|
-
### Routes
|
|
28
|
-
- **src/routes/upload.js** (97 lines)
|
|
29
|
-
- POST /media/upload endpoint
|
|
30
|
-
- File validation
|
|
31
|
-
- Job creation
|
|
32
|
-
- 202 response
|
|
33
|
-
|
|
34
|
-
- **src/routes/status.js** (77 lines)
|
|
35
|
-
- GET /media/status/:jobId endpoint
|
|
36
|
-
- Job status retrieval
|
|
37
|
-
- Media data response
|
|
38
|
-
- HTTP status codes
|
|
39
|
-
|
|
40
|
-
### Workers
|
|
41
|
-
- **src/workers/processor.js** (368 lines)
|
|
42
|
-
- Job queue polling
|
|
43
|
-
- Processing pipeline
|
|
44
|
-
- Pessimistic locking
|
|
45
|
-
- Error handling and retry
|
|
46
|
-
- Stale lock recovery
|
|
47
|
-
|
|
48
|
-
## Configuration Files
|
|
49
|
-
|
|
50
|
-
- **package.json** (40 lines)
|
|
51
|
-
- Dependencies and dev dependencies
|
|
52
|
-
- Scripts
|
|
53
|
-
- Package metadata
|
|
54
|
-
|
|
55
|
-
- **SCHEMA.prisma** (76 lines)
|
|
56
|
-
- MediaStatus enum
|
|
57
|
-
- MediaQueue model
|
|
58
|
-
- Media model
|
|
59
|
-
- Database indexes
|
|
60
|
-
- Example relations
|
|
61
|
-
|
|
62
|
-
## Documentation
|
|
63
|
-
|
|
64
|
-
- **README.md** (500+ lines)
|
|
65
|
-
- Feature overview
|
|
66
|
-
- Installation guide
|
|
67
|
-
- Setup instructions
|
|
68
|
-
- API documentation
|
|
69
|
-
- Frontend examples
|
|
70
|
-
- Configuration guide
|
|
71
|
-
- Troubleshooting
|
|
72
|
-
|
|
73
|
-
- **IMPLEMENTATION_SUMMARY.md** (400+ lines)
|
|
74
|
-
- Completed components
|
|
75
|
-
- Directory structure
|
|
76
|
-
- Processing pipeline
|
|
77
|
-
- Configuration options
|
|
78
|
-
- Quick start guide
|
|
79
|
-
- Design decisions
|
|
80
|
-
- Future enhancements
|
|
81
|
-
|
|
82
|
-
- **INTEGRATION_GUIDE.md** (350+ lines)
|
|
83
|
-
- Step-by-step integration
|
|
84
|
-
- Environment setup
|
|
85
|
-
- Prisma schema addition
|
|
86
|
-
- Plugin registration
|
|
87
|
-
- Frontend examples
|
|
88
|
-
- R2 setup
|
|
89
|
-
- Troubleshooting
|
|
90
|
-
- Usage examples
|
|
91
|
-
|
|
92
|
-
- **FILES.md** (this file)
|
|
93
|
-
- File listing and line counts
|
|
94
|
-
|
|
95
|
-
## Testing
|
|
96
|
-
|
|
97
|
-
- **test/xMedia.test.js** (280 lines)
|
|
98
|
-
- Plugin registration tests
|
|
99
|
-
- Configuration tests
|
|
100
|
-
- Route tests
|
|
101
|
-
- Variant preset tests
|
|
102
|
-
|
|
103
|
-
## Statistics
|
|
104
|
-
|
|
105
|
-
### Code Files
|
|
106
|
-
- Total lines: ~1,200+
|
|
107
|
-
- Files: 7 source files
|
|
108
|
-
- Services: 1
|
|
109
|
-
- Utilities: 1
|
|
110
|
-
- Routes: 2
|
|
111
|
-
- Workers: 1
|
|
112
|
-
|
|
113
|
-
### Documentation
|
|
114
|
-
- README: 500+ lines
|
|
115
|
-
- Implementation Summary: 400+ lines
|
|
116
|
-
- Integration Guide: 350+ lines
|
|
117
|
-
- Total docs: 1,250+ lines
|
|
118
|
-
|
|
119
|
-
### Total Project Size
|
|
120
|
-
- Source code: 1,200+ lines
|
|
121
|
-
- Tests: 280 lines
|
|
122
|
-
- Documentation: 1,250+ lines
|
|
123
|
-
- **Grand Total: 2,730+ lines**
|
|
124
|
-
|
|
125
|
-
## Directory Tree
|
|
126
|
-
|
|
127
|
-
```
|
|
128
|
-
xMedia/
|
|
129
|
-
├── src/
|
|
130
|
-
│ ├── xMedia.js (145 lines) - Main plugin
|
|
131
|
-
│ ├── services/
|
|
132
|
-
│ │ └── s3.js (168 lines) - R2/S3 integration
|
|
133
|
-
│ ├── utils/
|
|
134
|
-
│ │ └── image.js (230 lines) - Image processing
|
|
135
|
-
│ ├── routes/
|
|
136
|
-
│ │ ├── upload.js (97 lines) - Upload endpoint
|
|
137
|
-
│ │ └── status.js (77 lines) - Status endpoint
|
|
138
|
-
│ └── workers/
|
|
139
|
-
│ └── processor.js (368 lines) - Job processor
|
|
140
|
-
├── test/
|
|
141
|
-
│ └── xMedia.test.js (280 lines) - Test suite
|
|
142
|
-
├── package.json (40 lines)
|
|
143
|
-
├── SCHEMA.prisma (76 lines)
|
|
144
|
-
├── README.md (500+ lines)
|
|
145
|
-
├── IMPLEMENTATION_SUMMARY.md (400+ lines)
|
|
146
|
-
├── INTEGRATION_GUIDE.md (350+ lines)
|
|
147
|
-
└── FILES.md (this file)
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
## Feature Matrix
|
|
151
|
-
|
|
152
|
-
| Feature | File | Status |
|
|
153
|
-
|---------|------|--------|
|
|
154
|
-
| Plugin initialization | xMedia.js | ✅ |
|
|
155
|
-
| R2/S3 integration | services/s3.js | ✅ |
|
|
156
|
-
| File uploads | routes/upload.js | ✅ |
|
|
157
|
-
| Status checking | routes/status.js | ✅ |
|
|
158
|
-
| EXIF stripping | utils/image.js | ✅ |
|
|
159
|
-
| Variant generation | utils/image.js | ✅ |
|
|
160
|
-
| Blurhash generation | utils/image.js | ✅ |
|
|
161
|
-
| Image validation | utils/image.js | ✅ |
|
|
162
|
-
| Job queueing | routes/upload.js | ✅ |
|
|
163
|
-
| Worker processing | workers/processor.js | ✅ |
|
|
164
|
-
| Moderation hooks | workers/processor.js | ✅ |
|
|
165
|
-
| Retry logic | workers/processor.js | ✅ |
|
|
166
|
-
| Lock management | workers/processor.js | ✅ |
|
|
167
|
-
| Database models | SCHEMA.prisma | ✅ |
|
|
168
|
-
| API documentation | README.md | ✅ |
|
|
169
|
-
| Setup guide | INTEGRATION_GUIDE.md | ✅ |
|
|
170
|
-
| Tests | test/xMedia.test.js | ✅ |
|
|
171
|
-
|
|
172
|
-
## Implementation Status
|
|
173
|
-
|
|
174
|
-
- ✅ Core plugin complete
|
|
175
|
-
- ✅ S3/R2 integration complete
|
|
176
|
-
- ✅ Image processing complete
|
|
177
|
-
- ✅ Upload endpoint complete
|
|
178
|
-
- ✅ Status endpoint complete
|
|
179
|
-
- ✅ Worker processor complete
|
|
180
|
-
- ✅ Database schema defined
|
|
181
|
-
- ✅ Comprehensive documentation
|
|
182
|
-
- ✅ Integration guide
|
|
183
|
-
- ✅ Basic test suite
|
|
184
|
-
- ⏳ Moderation API integration (hook exists, stub ready)
|
|
185
|
-
- ⏳ Production monitoring setup
|
|
186
|
-
- ⏳ CDN cache configuration
|
|
187
|
-
|
|
188
|
-
## Ready for Production
|
|
189
|
-
|
|
190
|
-
This plugin is production-ready with:
|
|
191
|
-
- ✅ Complete error handling
|
|
192
|
-
- ✅ Comprehensive logging
|
|
193
|
-
- ✅ Database transactions
|
|
194
|
-
- ✅ Job retry logic
|
|
195
|
-
- ✅ Stale lock recovery
|
|
196
|
-
- ✅ Rate limiting hooks
|
|
197
|
-
- ✅ Security validation
|
|
198
|
-
- ✅ CORS configuration
|
|
199
|
-
- ✅ Performance optimization
|
|
200
|
-
|
|
201
|
-
## Next Steps for User
|
|
202
|
-
|
|
203
|
-
1. Copy files to your project
|
|
204
|
-
2. Install dependencies
|
|
205
|
-
3. Add Prisma schema
|
|
206
|
-
4. Configure R2 bucket
|
|
207
|
-
5. Register plugin
|
|
208
|
-
6. Build frontend integration
|
|
209
|
-
7. Deploy to production
|
|
210
|
-
8. Monitor job queue
|
|
211
|
-
|
|
212
|
-
**Total implementation time: ~2,730 lines of code and documentation**
|
|
@@ -1,427 +0,0 @@
|
|
|
1
|
-
# xMedia Plugin - Implementation Summary
|
|
2
|
-
|
|
3
|
-
## ✅ Completed Components
|
|
4
|
-
|
|
5
|
-
### 1. **Core Plugin** ✅
|
|
6
|
-
- **File**: `src/xMedia.js`
|
|
7
|
-
- **Features**:
|
|
8
|
-
- Plugin initialization with comprehensive configuration
|
|
9
|
-
- R2 client setup
|
|
10
|
-
- Route registration
|
|
11
|
-
- Worker initialization and management
|
|
12
|
-
- Default variant specifications
|
|
13
|
-
- Variant presets (avatar, member_photo, gallery, hero, content)
|
|
14
|
-
|
|
15
|
-
### 2. **R2/S3 Integration Service** ✅
|
|
16
|
-
- **File**: `src/services/s3.js`
|
|
17
|
-
- **Functions**:
|
|
18
|
-
- `initializeS3Client()` - Create S3 client for R2
|
|
19
|
-
- `uploadToS3()` - Upload file with metadata
|
|
20
|
-
- `downloadFromS3()` - Download file buffer
|
|
21
|
-
- `deleteFromS3()` - Delete single object
|
|
22
|
-
- `listFromS3()` - List objects with prefix
|
|
23
|
-
- `getSignedUrlForS3()` - Generate signed URLs for protected content
|
|
24
|
-
- `getPublicUrl()` - Generate public URL for media
|
|
25
|
-
- `batchDeleteFromS3()` - Delete multiple objects
|
|
26
|
-
|
|
27
|
-
### 3. **Image Processing Utilities** ✅
|
|
28
|
-
- **File**: `src/utils/image.js`
|
|
29
|
-
- **Functions**:
|
|
30
|
-
- `stripExif()` - Remove EXIF metadata while preserving orientation
|
|
31
|
-
- `getImageMetadata()` - Extract image properties
|
|
32
|
-
- `generateVariants()` - Create WebP variants at multiple sizes
|
|
33
|
-
- `generateBlurhash()` - Generate loading placeholder
|
|
34
|
-
- `calculateFitDimensions()` - Calculate aspect-ratio-preserving dimensions
|
|
35
|
-
- `getAspectRatio()` - Get aspect ratio string
|
|
36
|
-
- `validateImage()` - Validate image dimensions and format
|
|
37
|
-
|
|
38
|
-
### 4. **Upload Endpoint** ✅
|
|
39
|
-
- **File**: `src/routes/upload.js`
|
|
40
|
-
- **Route**: `POST /media/upload`
|
|
41
|
-
- **Features**:
|
|
42
|
-
- Multipart file upload handling
|
|
43
|
-
- File type validation
|
|
44
|
-
- File size validation
|
|
45
|
-
- Source type validation
|
|
46
|
-
- Staging bucket upload
|
|
47
|
-
- MediaQueue job creation
|
|
48
|
-
- 202 Accepted response with jobId
|
|
49
|
-
|
|
50
|
-
### 5. **Status Endpoint** ✅
|
|
51
|
-
- **File**: `src/routes/status.js`
|
|
52
|
-
- **Route**: `GET /media/status/:jobId`
|
|
53
|
-
- **Features**:
|
|
54
|
-
- Job status retrieval
|
|
55
|
-
- Job progress tracking
|
|
56
|
-
- Result retrieval when complete
|
|
57
|
-
- Moderation details when rejected
|
|
58
|
-
- Error details when failed
|
|
59
|
-
- Appropriate HTTP status codes (202, 200, 400, 500)
|
|
60
|
-
|
|
61
|
-
### 6. **Worker Processor** ✅
|
|
62
|
-
- **File**: `src/workers/processor.js`
|
|
63
|
-
- **Features**:
|
|
64
|
-
- Job queue polling
|
|
65
|
-
- Pessimistic locking (prevents duplicate processing)
|
|
66
|
-
- Job processing pipeline:
|
|
67
|
-
1. Download from staging
|
|
68
|
-
2. Strip EXIF
|
|
69
|
-
3. Content moderation (pluggable)
|
|
70
|
-
4. Generate variants
|
|
71
|
-
5. Generate blurhash
|
|
72
|
-
6. Upload to R2
|
|
73
|
-
7. Create Media record
|
|
74
|
-
8. Update job status
|
|
75
|
-
9. Cleanup staging
|
|
76
|
-
- Error handling with retry logic
|
|
77
|
-
- Stale lock recovery
|
|
78
|
-
- Worker lifecycle management
|
|
79
|
-
|
|
80
|
-
### 7. **Prisma Schema** ✅
|
|
81
|
-
- **File**: `SCHEMA.prisma`
|
|
82
|
-
- **Models**:
|
|
83
|
-
- `MediaStatus` enum
|
|
84
|
-
- `MediaQueue` model with locking and retry tracking
|
|
85
|
-
- `Media` model with URLs, metadata, and focal points
|
|
86
|
-
- Database indexes for performance
|
|
87
|
-
- Example relations for User/Band integration
|
|
88
|
-
|
|
89
|
-
### 8. **Package Configuration** ✅
|
|
90
|
-
- **File**: `package.json`
|
|
91
|
-
- **Dependencies**:
|
|
92
|
-
- @aws-sdk/client-s3
|
|
93
|
-
- @aws-sdk/s3-request-presigner
|
|
94
|
-
- blurhash
|
|
95
|
-
- fastify-plugin
|
|
96
|
-
- sharp
|
|
97
|
-
- **DevDependencies**: fastify, @fastify/multipart
|
|
98
|
-
|
|
99
|
-
### 9. **Documentation** ✅
|
|
100
|
-
- **File**: `README.md` (comprehensive)
|
|
101
|
-
- Feature overview
|
|
102
|
-
- Installation instructions
|
|
103
|
-
- Setup guide (Prisma, plugin registration)
|
|
104
|
-
- API endpoint documentation
|
|
105
|
-
- Frontend usage examples (React, Nuxt)
|
|
106
|
-
- Processing pipeline explanation
|
|
107
|
-
- Configuration guide
|
|
108
|
-
- Environment variables
|
|
109
|
-
- Performance tips
|
|
110
|
-
- Troubleshooting guide
|
|
111
|
-
|
|
112
|
-
### 10. **Test Suite** ✅
|
|
113
|
-
- **File**: `test/xMedia.test.js`
|
|
114
|
-
- **Coverage**:
|
|
115
|
-
- Plugin registration tests
|
|
116
|
-
- Configuration validation
|
|
117
|
-
- Route availability
|
|
118
|
-
- Variant presets
|
|
119
|
-
- Worker configuration
|
|
120
|
-
|
|
121
|
-
## 📁 Directory Structure
|
|
122
|
-
|
|
123
|
-
```
|
|
124
|
-
xMedia/
|
|
125
|
-
├── src/
|
|
126
|
-
│ ├── xMedia.js # Main plugin
|
|
127
|
-
│ ├── services/
|
|
128
|
-
│ │ └── s3.js # R2/S3 integration
|
|
129
|
-
│ ├── utils/
|
|
130
|
-
│ │ └── image.js # Image processing
|
|
131
|
-
│ ├── routes/
|
|
132
|
-
│ │ ├── upload.js # Upload endpoint
|
|
133
|
-
│ │ └── status.js # Status endpoint
|
|
134
|
-
│ └── workers/
|
|
135
|
-
│ └── processor.js # Job queue processor
|
|
136
|
-
├── test/
|
|
137
|
-
│ └── xMedia.test.js # Test suite
|
|
138
|
-
├── package.json # Dependencies
|
|
139
|
-
├── README.md # Documentation
|
|
140
|
-
├── SCHEMA.prisma # Database schema
|
|
141
|
-
└── IMPLEMENTATION_SUMMARY.md # This file
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
## 🔄 Processing Pipeline
|
|
145
|
-
|
|
146
|
-
```
|
|
147
|
-
Upload Request
|
|
148
|
-
↓
|
|
149
|
-
Validate File (type, size)
|
|
150
|
-
↓
|
|
151
|
-
Generate Staging Key
|
|
152
|
-
↓
|
|
153
|
-
Upload to R2 Staging Bucket
|
|
154
|
-
↓
|
|
155
|
-
Create MediaQueue Job
|
|
156
|
-
↓
|
|
157
|
-
Return 202 Accepted
|
|
158
|
-
↓
|
|
159
|
-
[Worker Polling]
|
|
160
|
-
↓
|
|
161
|
-
Lock Job (prevent duplicate processing)
|
|
162
|
-
↓
|
|
163
|
-
Download from Staging
|
|
164
|
-
↓
|
|
165
|
-
Strip EXIF Data
|
|
166
|
-
↓
|
|
167
|
-
Content Moderation (if configured)
|
|
168
|
-
├─ REJECTED → Clean up, mark REJECTED
|
|
169
|
-
└─ APPROVED
|
|
170
|
-
↓
|
|
171
|
-
Generate WebP Variants
|
|
172
|
-
↓
|
|
173
|
-
Generate Blurhash
|
|
174
|
-
↓
|
|
175
|
-
Upload Variants & Original to R2
|
|
176
|
-
↓
|
|
177
|
-
Create Media Record
|
|
178
|
-
↓
|
|
179
|
-
Update MediaQueue Status to COMPLETE
|
|
180
|
-
↓
|
|
181
|
-
Delete Staging File
|
|
182
|
-
↓
|
|
183
|
-
Ready for Frontend Consumption
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
## 🎛️ Configuration Options
|
|
187
|
-
|
|
188
|
-
```javascript
|
|
189
|
-
{
|
|
190
|
-
// Required
|
|
191
|
-
r2: {
|
|
192
|
-
endpoint: String, // https://[id].r2.cloudflarestorage.com
|
|
193
|
-
region: String, // 'auto' for R2
|
|
194
|
-
accessKeyId: String,
|
|
195
|
-
secretAccessKey: String,
|
|
196
|
-
bucket: String,
|
|
197
|
-
},
|
|
198
|
-
db: PrismaClient,
|
|
199
|
-
|
|
200
|
-
// Optional
|
|
201
|
-
moderation: {
|
|
202
|
-
provider: String, // 'rekognition', 'vision', etc.
|
|
203
|
-
apiKey: String,
|
|
204
|
-
// ... provider-specific config
|
|
205
|
-
},
|
|
206
|
-
|
|
207
|
-
variants: { // Override defaults
|
|
208
|
-
[key: String]: {
|
|
209
|
-
width: Number,
|
|
210
|
-
height: Number | null,
|
|
211
|
-
fit: 'cover' | 'inside',
|
|
212
|
-
}
|
|
213
|
-
},
|
|
214
|
-
|
|
215
|
-
worker: {
|
|
216
|
-
enabled: Boolean, // true
|
|
217
|
-
pollInterval: Number, // 5000ms
|
|
218
|
-
maxAttempts: Number, // 3
|
|
219
|
-
lockTimeout: Number, // 300000ms
|
|
220
|
-
failOnError: Boolean, // false
|
|
221
|
-
},
|
|
222
|
-
|
|
223
|
-
stagingPath: String, // 'staging'
|
|
224
|
-
mediaPath: String, // 'media'
|
|
225
|
-
originalsPath: String, // 'originals'
|
|
226
|
-
maxFileSize: Number, // 50MB
|
|
227
|
-
allowedMimeTypes: String[], // JPEG, PNG, WebP, GIF
|
|
228
|
-
}
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
## 🚀 Quick Start
|
|
232
|
-
|
|
233
|
-
### 1. Install Dependencies
|
|
234
|
-
```bash
|
|
235
|
-
npm install @xenterprises/fastify-xmedia @fastify/multipart
|
|
236
|
-
npm install -D fastify
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
### 2. Add Prisma Models
|
|
240
|
-
Copy models from `SCHEMA.prisma` to your `schema.prisma` and run:
|
|
241
|
-
```bash
|
|
242
|
-
npx prisma migrate dev
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
### 3. Register Plugin
|
|
246
|
-
```javascript
|
|
247
|
-
import xMedia from '@xenterprises/fastify-xmedia';
|
|
248
|
-
import multipart from '@fastify/multipart';
|
|
249
|
-
|
|
250
|
-
await fastify.register(multipart);
|
|
251
|
-
await fastify.register(xMedia, {
|
|
252
|
-
r2: { /* R2 config */ },
|
|
253
|
-
db: prisma,
|
|
254
|
-
});
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
### 4. Upload Image
|
|
258
|
-
```bash
|
|
259
|
-
curl -F "file=@photo.jpg" \
|
|
260
|
-
-F "sourceType=avatar" \
|
|
261
|
-
-F "sourceId=user123" \
|
|
262
|
-
http://localhost:3000/media/upload
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
### 5. Check Status
|
|
266
|
-
```bash
|
|
267
|
-
curl http://localhost:3000/media/status/[jobId]
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
## 🔌 Integrations
|
|
271
|
-
|
|
272
|
-
### Source Types & Variants
|
|
273
|
-
- **avatar**: xs, sm
|
|
274
|
-
- **member_photo**: xs, sm, md
|
|
275
|
-
- **gallery**: md, lg, xl
|
|
276
|
-
- **hero**: lg, xl, 2xl
|
|
277
|
-
- **content**: md, lg
|
|
278
|
-
|
|
279
|
-
### Content Moderation (Pluggable)
|
|
280
|
-
- AWS Rekognition
|
|
281
|
-
- Google Vision AI
|
|
282
|
-
- Sightengine
|
|
283
|
-
- Custom APIs
|
|
284
|
-
|
|
285
|
-
### Storage
|
|
286
|
-
- Cloudflare R2 (S3-compatible)
|
|
287
|
-
- AWS S3 (compatible)
|
|
288
|
-
- MinIO (compatible)
|
|
289
|
-
|
|
290
|
-
## 📊 Database Schema
|
|
291
|
-
|
|
292
|
-
### MediaQueue
|
|
293
|
-
- Job status tracking (PENDING, PROCESSING, COMPLETE, REJECTED, FAILED)
|
|
294
|
-
- Retry logic with max attempts
|
|
295
|
-
- Pessimistic locking (lockedAt, lockedBy)
|
|
296
|
-
- Error tracking
|
|
297
|
-
- Moderation results
|
|
298
|
-
|
|
299
|
-
### Media
|
|
300
|
-
- Variant URLs (JSON object)
|
|
301
|
-
- Original file URL
|
|
302
|
-
- Image properties (width, height, format, aspect ratio)
|
|
303
|
-
- Blurhash for loading placeholders
|
|
304
|
-
- Focal point for smart cropping
|
|
305
|
-
- Source tracking (sourceType, sourceId)
|
|
306
|
-
|
|
307
|
-
## ⚙️ Advanced Features
|
|
308
|
-
|
|
309
|
-
### Focal Points
|
|
310
|
-
- User-provided hint for smart cropping
|
|
311
|
-
- Stored as `{ x: 0-1, y: 0-1 }`
|
|
312
|
-
- Used by frontend for better crop positioning
|
|
313
|
-
|
|
314
|
-
### Blurhash
|
|
315
|
-
- Perceptual hash for image preview
|
|
316
|
-
- Instant UI placeholder while loading
|
|
317
|
-
- Generated from small thumbnail
|
|
318
|
-
|
|
319
|
-
### Variant Presets
|
|
320
|
-
- Different source types get different variants
|
|
321
|
-
- Avatar: small previews (xs, sm)
|
|
322
|
-
- Hero: large backgrounds (lg, xl, 2xl)
|
|
323
|
-
- Extensible with custom sourceTypes
|
|
324
|
-
|
|
325
|
-
### Retry Logic
|
|
326
|
-
- Automatic retry on processing failure
|
|
327
|
-
- Configurable max attempts (default: 3)
|
|
328
|
-
- Stale lock recovery (locks > 5 min)
|
|
329
|
-
|
|
330
|
-
### Cleanup
|
|
331
|
-
- Staging files auto-delete after 24h (R2 lifecycle)
|
|
332
|
-
- Manual cleanup functions provided
|
|
333
|
-
- Orphaned staging file cleanup
|
|
334
|
-
|
|
335
|
-
## 🧪 Testing
|
|
336
|
-
|
|
337
|
-
Run tests with:
|
|
338
|
-
```bash
|
|
339
|
-
npm test
|
|
340
|
-
```
|
|
341
|
-
|
|
342
|
-
Test coverage includes:
|
|
343
|
-
- Plugin registration
|
|
344
|
-
- Configuration validation
|
|
345
|
-
- Route availability
|
|
346
|
-
- Variant presets
|
|
347
|
-
- Worker setup
|
|
348
|
-
|
|
349
|
-
## 📝 Implementation Notes
|
|
350
|
-
|
|
351
|
-
### Design Decisions
|
|
352
|
-
|
|
353
|
-
1. **Job Queue Pattern**: Decouples upload from processing for better UX
|
|
354
|
-
2. **Pessimistic Locking**: Simple, effective duplicate prevention
|
|
355
|
-
3. **WebP Format**: Best compression for web images
|
|
356
|
-
4. **Variant Presets**: Balance between flexibility and simplicity
|
|
357
|
-
5. **Blurhash**: Industry-standard loading placeholder
|
|
358
|
-
|
|
359
|
-
### Error Handling
|
|
360
|
-
|
|
361
|
-
- File validation before upload
|
|
362
|
-
- Transaction-like processing (all-or-nothing)
|
|
363
|
-
- Retry logic with exponential backoff capability
|
|
364
|
-
- Graceful degradation if moderation unavailable
|
|
365
|
-
- Comprehensive error messages
|
|
366
|
-
|
|
367
|
-
### Performance
|
|
368
|
-
|
|
369
|
-
- Streaming file uploads (not loading full files in memory)
|
|
370
|
-
- Efficient image processing with sharp
|
|
371
|
-
- Indexed database queries for job polling
|
|
372
|
-
- Batch operations for cleanup
|
|
373
|
-
- Optional CDN caching
|
|
374
|
-
|
|
375
|
-
## 🔐 Security Considerations
|
|
376
|
-
|
|
377
|
-
- File type validation (whitelist)
|
|
378
|
-
- File size limits
|
|
379
|
-
- EXIF stripping (privacy protection)
|
|
380
|
-
- Content moderation (policy enforcement)
|
|
381
|
-
- Signed URLs for protected content
|
|
382
|
-
- CORS configuration for R2
|
|
383
|
-
|
|
384
|
-
## 🎯 Next Steps
|
|
385
|
-
|
|
386
|
-
### For Production
|
|
387
|
-
|
|
388
|
-
1. ✅ Implement moderation API integration
|
|
389
|
-
2. ✅ Set up R2 bucket and lifecycle policies
|
|
390
|
-
3. ✅ Add proper error logging/monitoring
|
|
391
|
-
4. ✅ Implement webhook callbacks for job completion
|
|
392
|
-
5. ✅ Add rate limiting per user/IP
|
|
393
|
-
6. ✅ Set up CDN in front of R2
|
|
394
|
-
7. ✅ Add image optimization (AVIF, etc.)
|
|
395
|
-
8. ✅ Implement analytics/usage tracking
|
|
396
|
-
|
|
397
|
-
### For Enhancement
|
|
398
|
-
|
|
399
|
-
- Direct browser uploads (presigned URLs)
|
|
400
|
-
- Video support with thumbnails
|
|
401
|
-
- Batch uploads
|
|
402
|
-
- Face detection for smart cropping
|
|
403
|
-
- Custom filters/effects
|
|
404
|
-
- Image comparison (similarity search)
|
|
405
|
-
|
|
406
|
-
## 📚 Documentation Files
|
|
407
|
-
|
|
408
|
-
- `README.md` - User-facing documentation
|
|
409
|
-
- `SCHEMA.prisma` - Database schema template
|
|
410
|
-
- `IMPLEMENTATION_SUMMARY.md` - This file
|
|
411
|
-
- Source code comments - Comprehensive JSDoc
|
|
412
|
-
|
|
413
|
-
## ✨ Summary
|
|
414
|
-
|
|
415
|
-
The xMedia plugin provides a production-ready image processing pipeline with:
|
|
416
|
-
- ✅ Complete file upload handling
|
|
417
|
-
- ✅ Automatic image optimization (EXIF strip, WebP variants)
|
|
418
|
-
- ✅ Content moderation hooks
|
|
419
|
-
- ✅ Job queue with retry logic
|
|
420
|
-
- ✅ R2 storage integration
|
|
421
|
-
- ✅ Status tracking and polling
|
|
422
|
-
- ✅ Comprehensive error handling
|
|
423
|
-
- ✅ Database-backed processing
|
|
424
|
-
- ✅ Extensive documentation
|
|
425
|
-
- ✅ Test coverage
|
|
426
|
-
|
|
427
|
-
All components are implemented and ready for integration into your Fastify application.
|
package/INTEGRATION_GUIDE.md
DELETED
|
@@ -1,554 +0,0 @@
|
|
|
1
|
-
# xMedia Integration Guide
|
|
2
|
-
|
|
3
|
-
Quick reference for integrating xMedia into your Fastify application.
|
|
4
|
-
|
|
5
|
-
## Step 1: Install Package
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm install @xenterprises/fastify-xmedia @fastify/multipart
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Step 2: Set Environment Variables
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
# .env
|
|
15
|
-
R2_ENDPOINT=https://[account-id].r2.cloudflarestorage.com
|
|
16
|
-
R2_ACCESS_KEY_ID=your_access_key
|
|
17
|
-
R2_SECRET_ACCESS_KEY=your_secret_key
|
|
18
|
-
R2_BUCKET=your-bucket-name
|
|
19
|
-
|
|
20
|
-
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
|
21
|
-
|
|
22
|
-
# Optional
|
|
23
|
-
MODERATION_PROVIDER=rekognition
|
|
24
|
-
MODERATION_API_KEY=your_api_key
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
## Step 3: Update Prisma Schema
|
|
28
|
-
|
|
29
|
-
Add this to your `schema.prisma`:
|
|
30
|
-
|
|
31
|
-
```prisma
|
|
32
|
-
enum MediaStatus {
|
|
33
|
-
PENDING
|
|
34
|
-
PROCESSING
|
|
35
|
-
COMPLETE
|
|
36
|
-
REJECTED
|
|
37
|
-
FAILED
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
enum ModerationResult {
|
|
41
|
-
APPROVED
|
|
42
|
-
REJECTED
|
|
43
|
-
FLAGGED
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
model MediaQueue {
|
|
47
|
-
id String @id @default(cuid())
|
|
48
|
-
status MediaStatus @default(PENDING)
|
|
49
|
-
sourceType String
|
|
50
|
-
sourceId String
|
|
51
|
-
stagingKey String
|
|
52
|
-
originalFilename String
|
|
53
|
-
mimeType String
|
|
54
|
-
fileSize Int
|
|
55
|
-
mediaId String?
|
|
56
|
-
media Media? @relation(fields: [mediaId], references: [id], onDelete: SetNull)
|
|
57
|
-
attempts Int @default(0)
|
|
58
|
-
maxAttempts Int @default(3)
|
|
59
|
-
errorMsg String?
|
|
60
|
-
moderationResult ModerationResult?
|
|
61
|
-
moderationDetails Json?
|
|
62
|
-
lockedAt DateTime?
|
|
63
|
-
lockedBy String?
|
|
64
|
-
createdAt DateTime @default(now())
|
|
65
|
-
updatedAt DateTime @updatedAt
|
|
66
|
-
|
|
67
|
-
@@index([status, createdAt])
|
|
68
|
-
@@index([sourceType, sourceId])
|
|
69
|
-
@@index([lockedAt])
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
model Media {
|
|
73
|
-
id String @id @default(cuid())
|
|
74
|
-
urls Json @default("{}")
|
|
75
|
-
originalUrl String
|
|
76
|
-
width Int
|
|
77
|
-
height Int
|
|
78
|
-
format String
|
|
79
|
-
aspectRatio String
|
|
80
|
-
blurhash String
|
|
81
|
-
focalPoint Json @default("{\"x\": 0.5, \"y\": 0.5}")
|
|
82
|
-
sourceType String
|
|
83
|
-
sourceId String
|
|
84
|
-
originalFilename String
|
|
85
|
-
mimeType String
|
|
86
|
-
fileSize Int
|
|
87
|
-
exifStripped Boolean @default(true)
|
|
88
|
-
createdAt DateTime @default(now())
|
|
89
|
-
updatedAt DateTime @updatedAt
|
|
90
|
-
queue MediaQueue[]
|
|
91
|
-
|
|
92
|
-
@@index([sourceType, sourceId])
|
|
93
|
-
@@index([createdAt])
|
|
94
|
-
}
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
Then migrate:
|
|
98
|
-
```bash
|
|
99
|
-
npx prisma migrate dev --name add_media_tables
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
## Step 4: Register Plugin in Fastify
|
|
103
|
-
|
|
104
|
-
```javascript
|
|
105
|
-
// server.js or main app file
|
|
106
|
-
import Fastify from 'fastify';
|
|
107
|
-
import multipart from '@fastify/multipart';
|
|
108
|
-
import xMedia from '@xenterprises/fastify-xmedia';
|
|
109
|
-
import { PrismaClient } from '@prisma/client';
|
|
110
|
-
|
|
111
|
-
const fastify = Fastify();
|
|
112
|
-
const prisma = new PrismaClient();
|
|
113
|
-
|
|
114
|
-
// Register multipart first (required for file uploads)
|
|
115
|
-
await fastify.register(multipart);
|
|
116
|
-
|
|
117
|
-
// Register xMedia plugin
|
|
118
|
-
await fastify.register(xMedia, {
|
|
119
|
-
// Required configuration
|
|
120
|
-
r2: {
|
|
121
|
-
endpoint: process.env.R2_ENDPOINT,
|
|
122
|
-
region: 'auto',
|
|
123
|
-
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
|
124
|
-
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
|
|
125
|
-
bucket: process.env.R2_BUCKET,
|
|
126
|
-
},
|
|
127
|
-
|
|
128
|
-
db: prisma,
|
|
129
|
-
|
|
130
|
-
// Optional: Content moderation
|
|
131
|
-
moderation: process.env.MODERATION_PROVIDER ? {
|
|
132
|
-
provider: process.env.MODERATION_PROVIDER,
|
|
133
|
-
apiKey: process.env.MODERATION_API_KEY,
|
|
134
|
-
} : null,
|
|
135
|
-
|
|
136
|
-
// Optional: Worker configuration
|
|
137
|
-
worker: {
|
|
138
|
-
enabled: true, // Process jobs in background
|
|
139
|
-
pollInterval: 5000, // Check for new jobs every 5 seconds
|
|
140
|
-
maxAttempts: 3, // Retry failed jobs 3 times
|
|
141
|
-
lockTimeout: 5 * 60 * 1000, // Release locks after 5 minutes
|
|
142
|
-
},
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
await fastify.listen({ port: 3000 });
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
## Step 5: Create Frontend Upload Form
|
|
149
|
-
|
|
150
|
-
### React Example
|
|
151
|
-
|
|
152
|
-
```jsx
|
|
153
|
-
import { useState } from 'react';
|
|
154
|
-
|
|
155
|
-
export function AvatarUpload({ userId, onComplete }) {
|
|
156
|
-
const [jobId, setJobId] = useState(null);
|
|
157
|
-
const [status, setStatus] = useState(null);
|
|
158
|
-
const [loading, setLoading] = useState(false);
|
|
159
|
-
const [error, setError] = useState(null);
|
|
160
|
-
|
|
161
|
-
const handleUpload = async (file) => {
|
|
162
|
-
setLoading(true);
|
|
163
|
-
const formData = new FormData();
|
|
164
|
-
formData.append('file', file);
|
|
165
|
-
formData.append('sourceType', 'avatar');
|
|
166
|
-
formData.append('sourceId', userId);
|
|
167
|
-
|
|
168
|
-
try {
|
|
169
|
-
const response = await fetch('/media/upload', {
|
|
170
|
-
method: 'POST',
|
|
171
|
-
body: formData,
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
const data = await response.json();
|
|
175
|
-
setJobId(data.jobId);
|
|
176
|
-
pollStatus(data.jobId);
|
|
177
|
-
} catch (err) {
|
|
178
|
-
setError(err.message);
|
|
179
|
-
setLoading(false);
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
const pollStatus = async (id) => {
|
|
184
|
-
const maxAttempts = 120; // 2 minutes with 1s intervals
|
|
185
|
-
let attempts = 0;
|
|
186
|
-
|
|
187
|
-
const interval = setInterval(async () => {
|
|
188
|
-
attempts++;
|
|
189
|
-
|
|
190
|
-
try {
|
|
191
|
-
const response = await fetch(`/media/status/${id}`);
|
|
192
|
-
const data = await response.json();
|
|
193
|
-
setStatus(data);
|
|
194
|
-
|
|
195
|
-
if (data.status === 'COMPLETE') {
|
|
196
|
-
setLoading(false);
|
|
197
|
-
onComplete?.(data.media);
|
|
198
|
-
clearInterval(interval);
|
|
199
|
-
} else if (data.status === 'REJECTED') {
|
|
200
|
-
setError(`Image rejected: ${data.reason}`);
|
|
201
|
-
setLoading(false);
|
|
202
|
-
clearInterval(interval);
|
|
203
|
-
} else if (data.status === 'FAILED') {
|
|
204
|
-
setError(`Processing failed: ${data.error}`);
|
|
205
|
-
setLoading(false);
|
|
206
|
-
clearInterval(interval);
|
|
207
|
-
} else if (attempts >= maxAttempts) {
|
|
208
|
-
setError('Processing timeout');
|
|
209
|
-
setLoading(false);
|
|
210
|
-
clearInterval(interval);
|
|
211
|
-
}
|
|
212
|
-
} catch (err) {
|
|
213
|
-
console.error('Status check error:', err);
|
|
214
|
-
}
|
|
215
|
-
}, 1000);
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
return (
|
|
219
|
-
<div>
|
|
220
|
-
<input
|
|
221
|
-
type="file"
|
|
222
|
-
accept="image/*"
|
|
223
|
-
onChange={(e) => handleUpload(e.target.files[0])}
|
|
224
|
-
disabled={loading}
|
|
225
|
-
/>
|
|
226
|
-
|
|
227
|
-
{loading && <p>Processing...</p>}
|
|
228
|
-
{error && <p style={{ color: 'red' }}>{error}</p>}
|
|
229
|
-
|
|
230
|
-
{status?.media && (
|
|
231
|
-
<div>
|
|
232
|
-
<img src={status.media.urls.sm} alt="Avatar" width={200} />
|
|
233
|
-
<p>Upload complete!</p>
|
|
234
|
-
</div>
|
|
235
|
-
)}
|
|
236
|
-
</div>
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
### Nuxt Example
|
|
242
|
-
|
|
243
|
-
```vue
|
|
244
|
-
<template>
|
|
245
|
-
<div class="upload-container">
|
|
246
|
-
<input
|
|
247
|
-
ref="fileInput"
|
|
248
|
-
type="file"
|
|
249
|
-
accept="image/*"
|
|
250
|
-
@change="handleFileSelect"
|
|
251
|
-
:disabled="loading"
|
|
252
|
-
/>
|
|
253
|
-
|
|
254
|
-
<div v-if="loading" class="loading">
|
|
255
|
-
Processing image...
|
|
256
|
-
</div>
|
|
257
|
-
|
|
258
|
-
<div v-if="error" class="error">
|
|
259
|
-
{{ error }}
|
|
260
|
-
</div>
|
|
261
|
-
|
|
262
|
-
<div v-if="media" class="complete">
|
|
263
|
-
<img :src="media.urls.sm" :alt="media.originalFilename" />
|
|
264
|
-
<p>Upload complete!</p>
|
|
265
|
-
</div>
|
|
266
|
-
</div>
|
|
267
|
-
</template>
|
|
268
|
-
|
|
269
|
-
<script setup>
|
|
270
|
-
import { ref } from 'vue';
|
|
271
|
-
|
|
272
|
-
const props = defineProps({
|
|
273
|
-
sourceType: { type: String, default: 'avatar' },
|
|
274
|
-
sourceId: { type: String, required: true },
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
const emit = defineEmits(['complete']);
|
|
278
|
-
|
|
279
|
-
const fileInput = ref(null);
|
|
280
|
-
const jobId = ref(null);
|
|
281
|
-
const status = ref(null);
|
|
282
|
-
const media = ref(null);
|
|
283
|
-
const loading = ref(false);
|
|
284
|
-
const error = ref(null);
|
|
285
|
-
|
|
286
|
-
const handleFileSelect = async (event) => {
|
|
287
|
-
const file = event.target.files?.[0];
|
|
288
|
-
if (!file) return;
|
|
289
|
-
|
|
290
|
-
loading.value = true;
|
|
291
|
-
error.value = null;
|
|
292
|
-
|
|
293
|
-
const formData = new FormData();
|
|
294
|
-
formData.append('file', file);
|
|
295
|
-
formData.append('sourceType', props.sourceType);
|
|
296
|
-
formData.append('sourceId', props.sourceId);
|
|
297
|
-
|
|
298
|
-
try {
|
|
299
|
-
const response = await fetch('/media/upload', {
|
|
300
|
-
method: 'POST',
|
|
301
|
-
body: formData,
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
const data = await response.json();
|
|
305
|
-
jobId.value = data.jobId;
|
|
306
|
-
pollStatus();
|
|
307
|
-
} catch (err) {
|
|
308
|
-
error.value = `Upload failed: ${err.message}`;
|
|
309
|
-
loading.value = false;
|
|
310
|
-
}
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
const pollStatus = async () => {
|
|
314
|
-
const maxAttempts = 120;
|
|
315
|
-
let attempts = 0;
|
|
316
|
-
|
|
317
|
-
const interval = setInterval(async () => {
|
|
318
|
-
attempts++;
|
|
319
|
-
|
|
320
|
-
try {
|
|
321
|
-
const response = await fetch(`/media/status/${jobId.value}`);
|
|
322
|
-
const data = await response.json();
|
|
323
|
-
status.value = data;
|
|
324
|
-
|
|
325
|
-
if (data.status === 'COMPLETE') {
|
|
326
|
-
media.value = data.media;
|
|
327
|
-
loading.value = false;
|
|
328
|
-
emit('complete', data.media);
|
|
329
|
-
clearInterval(interval);
|
|
330
|
-
} else if (data.status === 'REJECTED') {
|
|
331
|
-
error.value = `Image rejected: ${data.reason}`;
|
|
332
|
-
loading.value = false;
|
|
333
|
-
clearInterval(interval);
|
|
334
|
-
} else if (data.status === 'FAILED') {
|
|
335
|
-
error.value = `Processing failed: ${data.error}`;
|
|
336
|
-
loading.value = false;
|
|
337
|
-
clearInterval(interval);
|
|
338
|
-
} else if (attempts >= maxAttempts) {
|
|
339
|
-
error.value = 'Processing timeout';
|
|
340
|
-
loading.value = false;
|
|
341
|
-
clearInterval(interval);
|
|
342
|
-
}
|
|
343
|
-
} catch (err) {
|
|
344
|
-
console.error('Status check error:', err);
|
|
345
|
-
}
|
|
346
|
-
}, 1000);
|
|
347
|
-
};
|
|
348
|
-
</script>
|
|
349
|
-
|
|
350
|
-
<style scoped>
|
|
351
|
-
.upload-container {
|
|
352
|
-
display: flex;
|
|
353
|
-
flex-direction: column;
|
|
354
|
-
gap: 1rem;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
.loading {
|
|
358
|
-
color: #0066cc;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
.error {
|
|
362
|
-
color: #cc0000;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
.complete {
|
|
366
|
-
color: #00aa00;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
.complete img {
|
|
370
|
-
max-width: 200px;
|
|
371
|
-
border-radius: 8px;
|
|
372
|
-
margin: 1rem 0;
|
|
373
|
-
}
|
|
374
|
-
</style>
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
## Step 6: Usage Examples
|
|
378
|
-
|
|
379
|
-
### Upload an Avatar
|
|
380
|
-
|
|
381
|
-
```bash
|
|
382
|
-
curl -X POST \
|
|
383
|
-
-F "file=@avatar.jpg" \
|
|
384
|
-
-F "sourceType=avatar" \
|
|
385
|
-
-F "sourceId=user-123" \
|
|
386
|
-
http://localhost:3000/media/upload
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
Response:
|
|
390
|
-
```json
|
|
391
|
-
{
|
|
392
|
-
"jobId": "clh7k9w1j000...",
|
|
393
|
-
"message": "File uploaded. Processing started.",
|
|
394
|
-
"statusUrl": "/media/status/clh7k9w1j000..."
|
|
395
|
-
}
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
### Check Processing Status
|
|
399
|
-
|
|
400
|
-
```bash
|
|
401
|
-
curl http://localhost:3000/media/status/clh7k9w1j000...
|
|
402
|
-
```
|
|
403
|
-
|
|
404
|
-
When complete:
|
|
405
|
-
```json
|
|
406
|
-
{
|
|
407
|
-
"jobId": "clh7k9w1j000...",
|
|
408
|
-
"status": "COMPLETE",
|
|
409
|
-
"sourceType": "avatar",
|
|
410
|
-
"sourceId": "user-123",
|
|
411
|
-
"media": {
|
|
412
|
-
"id": "media-...",
|
|
413
|
-
"urls": {
|
|
414
|
-
"xs": "https://bucket.r2.dev/media/avatar/user-123/.../xs.webp",
|
|
415
|
-
"sm": "https://bucket.r2.dev/media/avatar/user-123/.../sm.webp"
|
|
416
|
-
},
|
|
417
|
-
"originalUrl": "https://bucket.r2.dev/originals/avatar/user-123/.../original.jpg",
|
|
418
|
-
"width": 1920,
|
|
419
|
-
"height": 1920,
|
|
420
|
-
"aspectRatio": "1:1",
|
|
421
|
-
"blurhash": "UeKUpMx..."
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
```
|
|
425
|
-
|
|
426
|
-
## Step 7: R2 Setup
|
|
427
|
-
|
|
428
|
-
### Create R2 Bucket
|
|
429
|
-
|
|
430
|
-
1. Go to Cloudflare dashboard
|
|
431
|
-
2. Navigate to R2
|
|
432
|
-
3. Create new bucket
|
|
433
|
-
4. Note the endpoint URL
|
|
434
|
-
|
|
435
|
-
### Set CORS Policy
|
|
436
|
-
|
|
437
|
-
In R2 bucket settings:
|
|
438
|
-
```json
|
|
439
|
-
{
|
|
440
|
-
"CORSRules": [
|
|
441
|
-
{
|
|
442
|
-
"AllowedOrigins": ["https://yourdomain.com"],
|
|
443
|
-
"AllowedMethods": ["GET"],
|
|
444
|
-
"AllowedHeaders": ["*"],
|
|
445
|
-
"MaxAgeSeconds": 3600
|
|
446
|
-
}
|
|
447
|
-
]
|
|
448
|
-
}
|
|
449
|
-
```
|
|
450
|
-
|
|
451
|
-
### Set Lifecycle Policy
|
|
452
|
-
|
|
453
|
-
In R2 bucket settings:
|
|
454
|
-
- Delete objects in `/staging/` older than 1 day
|
|
455
|
-
- Keep `/media/` and `/originals/` permanently
|
|
456
|
-
|
|
457
|
-
## Step 8: Optional - Connect to User Model
|
|
458
|
-
|
|
459
|
-
Add to your User Prisma model:
|
|
460
|
-
|
|
461
|
-
```prisma
|
|
462
|
-
model User {
|
|
463
|
-
id String @id @default(cuid())
|
|
464
|
-
// ... other fields
|
|
465
|
-
|
|
466
|
-
// Avatar relationship
|
|
467
|
-
avatar Media? @relation(fields: [avatarId], references: [id], onDelete: SetNull)
|
|
468
|
-
avatarId String?
|
|
469
|
-
}
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
Then update avatar:
|
|
473
|
-
|
|
474
|
-
```javascript
|
|
475
|
-
// After upload completes
|
|
476
|
-
await prisma.user.update({
|
|
477
|
-
where: { id: userId },
|
|
478
|
-
data: { avatarId: media.id },
|
|
479
|
-
});
|
|
480
|
-
```
|
|
481
|
-
|
|
482
|
-
## Step 9: Display Images
|
|
483
|
-
|
|
484
|
-
```jsx
|
|
485
|
-
// In your React/Nuxt components
|
|
486
|
-
function UserProfile({ user }) {
|
|
487
|
-
const avatar = user.avatar;
|
|
488
|
-
|
|
489
|
-
if (!avatar) return <DefaultAvatar />;
|
|
490
|
-
|
|
491
|
-
return (
|
|
492
|
-
<img
|
|
493
|
-
src={avatar.urls.sm} // Mobile
|
|
494
|
-
srcSet={`
|
|
495
|
-
${avatar.urls.xs} 80w,
|
|
496
|
-
${avatar.urls.sm} 200w
|
|
497
|
-
`}
|
|
498
|
-
alt={user.name}
|
|
499
|
-
style={{ borderRadius: '50%', width: 200 }}
|
|
500
|
-
/>
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
```
|
|
504
|
-
|
|
505
|
-
## Step 10: Error Handling
|
|
506
|
-
|
|
507
|
-
Common issues and solutions:
|
|
508
|
-
|
|
509
|
-
### R2 Upload Fails
|
|
510
|
-
- ✅ Verify credentials
|
|
511
|
-
- ✅ Check bucket name
|
|
512
|
-
- ✅ Ensure endpoint URL is correct
|
|
513
|
-
- ✅ Check CORS policy
|
|
514
|
-
|
|
515
|
-
### Jobs Stuck in PROCESSING
|
|
516
|
-
- ✅ Jobs auto-recover after 5 minutes
|
|
517
|
-
- ✅ Check worker logs
|
|
518
|
-
- ✅ Verify database connectivity
|
|
519
|
-
|
|
520
|
-
### Images Rejected
|
|
521
|
-
- ✅ Check moderation settings
|
|
522
|
-
- ✅ Review moderation flags in job details
|
|
523
|
-
- ✅ Adjust moderation thresholds
|
|
524
|
-
|
|
525
|
-
## Troubleshooting Checklist
|
|
526
|
-
|
|
527
|
-
- [ ] Prisma models migrated
|
|
528
|
-
- [ ] Environment variables set
|
|
529
|
-
- [ ] R2 bucket created and configured
|
|
530
|
-
- [ ] CORS policy set on R2
|
|
531
|
-
- [ ] Lifecycle policy set on R2
|
|
532
|
-
- [ ] Plugin registered in correct order
|
|
533
|
-
- [ ] `@fastify/multipart` registered before xMedia
|
|
534
|
-
- [ ] Database connection working
|
|
535
|
-
- [ ] Worker is running (check logs)
|
|
536
|
-
|
|
537
|
-
## Support
|
|
538
|
-
|
|
539
|
-
For issues or questions:
|
|
540
|
-
- Check `README.md` for detailed documentation
|
|
541
|
-
- Review `IMPLEMENTATION_SUMMARY.md` for architecture
|
|
542
|
-
- Check worker logs: `console.info()` statements
|
|
543
|
-
- Inspect `MediaQueue` table for job details
|
|
544
|
-
|
|
545
|
-
## Next Steps
|
|
546
|
-
|
|
547
|
-
1. ✅ Upload test image
|
|
548
|
-
2. ✅ Verify processing completes
|
|
549
|
-
3. ✅ Display images in UI
|
|
550
|
-
4. ✅ Add moderation (optional)
|
|
551
|
-
5. ✅ Set up monitoring
|
|
552
|
-
6. ✅ Configure CDN cache
|
|
553
|
-
|
|
554
|
-
Happy image uploading! 🎉
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
// test/xImagePipeline.test.js
|
|
2
|
-
import { test } from "node:test";
|
|
3
|
-
import assert from "node:assert";
|
|
4
|
-
import Fastify from "fastify";
|
|
5
|
-
import multipart from "@fastify/multipart";
|
|
6
|
-
import xImagePipeline from "../src/xImagePipeline.js";
|
|
7
|
-
|
|
8
|
-
// Mock database
|
|
9
|
-
const mockDb = {
|
|
10
|
-
mediaQueue: {
|
|
11
|
-
create: async (data) => ({
|
|
12
|
-
id: "test-job-" + Date.now(),
|
|
13
|
-
...data.data,
|
|
14
|
-
}),
|
|
15
|
-
findUnique: async (query) => ({
|
|
16
|
-
id: query.where.id,
|
|
17
|
-
status: "COMPLETE",
|
|
18
|
-
sourceType: "avatar",
|
|
19
|
-
sourceId: "user123",
|
|
20
|
-
}),
|
|
21
|
-
findFirst: async () => null,
|
|
22
|
-
update: async (query) => ({ count: 1 }),
|
|
23
|
-
updateMany: async () => ({ count: 0 }),
|
|
24
|
-
},
|
|
25
|
-
media: {
|
|
26
|
-
create: async (data) => ({
|
|
27
|
-
id: "media-123",
|
|
28
|
-
...data.data,
|
|
29
|
-
}),
|
|
30
|
-
},
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
// Mock R2 config
|
|
34
|
-
const mockR2Config = {
|
|
35
|
-
endpoint: "https://example.r2.cloudflarestorage.com",
|
|
36
|
-
region: "auto",
|
|
37
|
-
accessKeyId: "mock-key",
|
|
38
|
-
secretAccessKey: "mock-secret",
|
|
39
|
-
bucket: "test-bucket",
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
test("xImagePipeline Plugin - registers successfully with required config", async () => {
|
|
43
|
-
const fastify = Fastify({ logger: false });
|
|
44
|
-
try {
|
|
45
|
-
await fastify.register(multipart);
|
|
46
|
-
await fastify.register(xImagePipeline, {
|
|
47
|
-
r2: mockR2Config,
|
|
48
|
-
db: mockDb,
|
|
49
|
-
});
|
|
50
|
-
assert.ok(true, "Plugin registered successfully");
|
|
51
|
-
} finally {
|
|
52
|
-
try {
|
|
53
|
-
await fastify.close();
|
|
54
|
-
} catch {
|
|
55
|
-
// Ignore
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("xImagePipeline Plugin - throws error without R2 config", async () => {
|
|
61
|
-
const fastify = Fastify({ logger: false });
|
|
62
|
-
try {
|
|
63
|
-
await assert.rejects(
|
|
64
|
-
async () => {
|
|
65
|
-
await fastify.register(multipart);
|
|
66
|
-
await fastify.register(xImagePipeline, {
|
|
67
|
-
db: mockDb,
|
|
68
|
-
});
|
|
69
|
-
},
|
|
70
|
-
/R2 configuration is required/
|
|
71
|
-
);
|
|
72
|
-
} finally {
|
|
73
|
-
try {
|
|
74
|
-
await fastify.close();
|
|
75
|
-
} catch {
|
|
76
|
-
// Ignore
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("xImagePipeline Plugin - throws error without database", async () => {
|
|
82
|
-
const fastify = Fastify({ logger: false });
|
|
83
|
-
try {
|
|
84
|
-
await assert.rejects(
|
|
85
|
-
async () => {
|
|
86
|
-
await fastify.register(multipart);
|
|
87
|
-
await fastify.register(xImagePipeline, {
|
|
88
|
-
r2: mockR2Config,
|
|
89
|
-
});
|
|
90
|
-
},
|
|
91
|
-
/Database instance/
|
|
92
|
-
);
|
|
93
|
-
} finally {
|
|
94
|
-
try {
|
|
95
|
-
await fastify.close();
|
|
96
|
-
} catch {
|
|
97
|
-
// Ignore
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("xImagePipeline Routes - GET /media/status/:jobId returns job status", async () => {
|
|
103
|
-
const fastify = Fastify({ logger: false });
|
|
104
|
-
try {
|
|
105
|
-
await fastify.register(multipart);
|
|
106
|
-
await fastify.register(xImagePipeline, {
|
|
107
|
-
r2: mockR2Config,
|
|
108
|
-
db: mockDb,
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
const response = await fastify.inject({
|
|
112
|
-
method: "GET",
|
|
113
|
-
url: "/image-pipeline/status/test-job-123",
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
assert.equal(response.statusCode, 200);
|
|
117
|
-
const body = JSON.parse(response.payload);
|
|
118
|
-
assert.ok(body.jobId, "Response should have jobId");
|
|
119
|
-
assert.ok(body.status, "Response should have status");
|
|
120
|
-
} finally {
|
|
121
|
-
try {
|
|
122
|
-
await fastify.close();
|
|
123
|
-
} catch {
|
|
124
|
-
// Ignore
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test("xImagePipeline Configuration - accepts custom variants config", async () => {
|
|
130
|
-
const fastify = Fastify({ logger: false });
|
|
131
|
-
const customConfig = {
|
|
132
|
-
r2: mockR2Config,
|
|
133
|
-
db: mockDb,
|
|
134
|
-
variants: {
|
|
135
|
-
custom: {
|
|
136
|
-
xs: { width: 100, height: 100, fit: "cover" },
|
|
137
|
-
lg: { width: 800, height: 600, fit: "inside" },
|
|
138
|
-
},
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
try {
|
|
142
|
-
await fastify.register(multipart);
|
|
143
|
-
await fastify.register(xImagePipeline, customConfig);
|
|
144
|
-
assert.ok(true, "Plugin registered with custom variants");
|
|
145
|
-
} finally {
|
|
146
|
-
try {
|
|
147
|
-
await fastify.close();
|
|
148
|
-
} catch {
|
|
149
|
-
// Ignore
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
test("xImagePipeline Configuration - accepts worker configuration", async () => {
|
|
155
|
-
const fastify = Fastify({ logger: false });
|
|
156
|
-
const configWithWorker = {
|
|
157
|
-
r2: mockR2Config,
|
|
158
|
-
db: mockDb,
|
|
159
|
-
worker: {
|
|
160
|
-
enabled: true,
|
|
161
|
-
pollInterval: 10000,
|
|
162
|
-
maxAttempts: 5,
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
try {
|
|
166
|
-
await fastify.register(multipart);
|
|
167
|
-
await fastify.register(xImagePipeline, configWithWorker);
|
|
168
|
-
assert.ok(true, "Plugin registered with worker config");
|
|
169
|
-
} finally {
|
|
170
|
-
try {
|
|
171
|
-
await fastify.close();
|
|
172
|
-
} catch {
|
|
173
|
-
// Ignore
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test("xImagePipeline Variant Presets - has predefined variant presets", async () => {
|
|
179
|
-
const fastify = Fastify({ logger: false });
|
|
180
|
-
try {
|
|
181
|
-
await fastify.register(multipart);
|
|
182
|
-
await fastify.register(xImagePipeline, {
|
|
183
|
-
r2: mockR2Config,
|
|
184
|
-
db: mockDb,
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
// Just verify plugin registered - variants are available via plugin configuration
|
|
188
|
-
assert.ok(true, "Plugin has variant presets configured");
|
|
189
|
-
} finally {
|
|
190
|
-
try {
|
|
191
|
-
await fastify.close();
|
|
192
|
-
} catch {
|
|
193
|
-
// Ignore
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
});
|