@xenterprises/fastify-ximagepipeline 1.0.0
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/FILES.md +212 -0
- package/IMPLEMENTATION_SUMMARY.md +427 -0
- package/INTEGRATION_GUIDE.md +554 -0
- package/README.md +488 -0
- package/SCHEMA.prisma +113 -0
- package/package.json +55 -0
- package/src/routes/status.js +88 -0
- package/src/routes/upload.js +132 -0
- package/src/services/s3.js +154 -0
- package/src/utils/image.js +308 -0
- package/src/workers/processor.js +264 -0
- package/src/xImagePipeline.js +164 -0
- package/test/xImagePipeline.test.js +196 -0
package/README.md
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
# xMedia Plugin for Fastify v5
|
|
2
|
+
|
|
3
|
+
Fastify plugin for handling image uploads with automatic EXIF stripping, content moderation, WebP variant generation, and Cloudflare R2 storage using a job queue pattern.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
โจ **Core Capabilities**
|
|
8
|
+
- ๐ผ๏ธ **Image Upload** - Multipart file upload with validation
|
|
9
|
+
- ๐ **EXIF Stripping** - Remove metadata while preserving orientation
|
|
10
|
+
- ๐จ **Variant Generation** - Automatic WebP variants at multiple sizes
|
|
11
|
+
- ๐ฆ **R2 Storage** - Direct integration with Cloudflare R2 (S3-compatible)
|
|
12
|
+
- โณ **Job Queue** - Database-backed processing queue with retry logic
|
|
13
|
+
- ๐ **Content Moderation** - Pluggable moderation API support
|
|
14
|
+
- ๐ฏ **Focal Points** - Smart cropping hints for UI
|
|
15
|
+
- ๐ **Blurhash** - Loading placeholders for instant UI feedback
|
|
16
|
+
- ๐งน **Cleanup** - Automatic staging cleanup and stale job recovery
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @xenterprises/fastify-xmedia @fastify/multipart
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
### 1. Add Prisma Models
|
|
27
|
+
|
|
28
|
+
Add these models to your `schema.prisma`:
|
|
29
|
+
|
|
30
|
+
```prisma
|
|
31
|
+
enum MediaStatus {
|
|
32
|
+
PENDING
|
|
33
|
+
PROCESSING
|
|
34
|
+
COMPLETE
|
|
35
|
+
REJECTED
|
|
36
|
+
FAILED
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
model MediaQueue {
|
|
40
|
+
id String @id @default(cuid())
|
|
41
|
+
status MediaStatus @default(PENDING)
|
|
42
|
+
sourceType String
|
|
43
|
+
sourceId String
|
|
44
|
+
stagingKey String
|
|
45
|
+
originalFilename String
|
|
46
|
+
mimeType String
|
|
47
|
+
fileSize Int
|
|
48
|
+
mediaId String?
|
|
49
|
+
media Media? @relation(fields: [mediaId], references: [id], onDelete: SetNull)
|
|
50
|
+
attempts Int @default(0)
|
|
51
|
+
maxAttempts Int @default(3)
|
|
52
|
+
errorMsg String?
|
|
53
|
+
moderationResult String?
|
|
54
|
+
moderationDetails Json?
|
|
55
|
+
lockedAt DateTime?
|
|
56
|
+
lockedBy String?
|
|
57
|
+
createdAt DateTime @default(now())
|
|
58
|
+
updatedAt DateTime @updatedAt
|
|
59
|
+
|
|
60
|
+
@@index([status, createdAt])
|
|
61
|
+
@@index([sourceType, sourceId])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
model Media {
|
|
65
|
+
id String @id @default(cuid())
|
|
66
|
+
urls Json @default("{}")
|
|
67
|
+
originalUrl String
|
|
68
|
+
width Int
|
|
69
|
+
height Int
|
|
70
|
+
format String
|
|
71
|
+
aspectRatio String
|
|
72
|
+
blurhash String
|
|
73
|
+
focalPoint Json @default("{\"x\": 0.5, \"y\": 0.5}")
|
|
74
|
+
sourceType String
|
|
75
|
+
sourceId String
|
|
76
|
+
originalFilename String
|
|
77
|
+
mimeType String
|
|
78
|
+
fileSize Int
|
|
79
|
+
exifStripped Boolean @default(true)
|
|
80
|
+
createdAt DateTime @default(now())
|
|
81
|
+
updatedAt DateTime @updatedAt
|
|
82
|
+
queue MediaQueue[]
|
|
83
|
+
|
|
84
|
+
@@index([sourceType, sourceId])
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 2. Register Plugin
|
|
89
|
+
|
|
90
|
+
```javascript
|
|
91
|
+
import Fastify from 'fastify';
|
|
92
|
+
import xMedia from '@xenterprises/fastify-xmedia';
|
|
93
|
+
import multipart from '@fastify/multipart';
|
|
94
|
+
import { PrismaClient } from '@prisma/client';
|
|
95
|
+
|
|
96
|
+
const fastify = Fastify();
|
|
97
|
+
const prisma = new PrismaClient();
|
|
98
|
+
|
|
99
|
+
await fastify.register(multipart);
|
|
100
|
+
|
|
101
|
+
await fastify.register(xMedia, {
|
|
102
|
+
// R2 Configuration
|
|
103
|
+
r2: {
|
|
104
|
+
endpoint: process.env.R2_ENDPOINT,
|
|
105
|
+
region: 'auto',
|
|
106
|
+
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
|
107
|
+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
|
|
108
|
+
bucket: process.env.R2_BUCKET,
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
// Database connection
|
|
112
|
+
db: prisma,
|
|
113
|
+
|
|
114
|
+
// Optional: Content moderation
|
|
115
|
+
moderation: {
|
|
116
|
+
provider: 'rekognition', // or 'vision', 'sightengine', etc.
|
|
117
|
+
apiKey: process.env.MODERATION_API_KEY,
|
|
118
|
+
// provider-specific options...
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
// Optional: Customize variant specs
|
|
122
|
+
variants: {
|
|
123
|
+
xs: { width: 80, height: 80, fit: 'cover' },
|
|
124
|
+
sm: { width: 200, height: 200, fit: 'cover' },
|
|
125
|
+
md: { width: 600, height: null, fit: 'inside' },
|
|
126
|
+
lg: { width: 1200, height: null, fit: 'inside' },
|
|
127
|
+
xl: { width: 1920, height: null, fit: 'inside' },
|
|
128
|
+
'2xl': { width: 2560, height: null, fit: 'inside' },
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
// Optional: Worker configuration
|
|
132
|
+
worker: {
|
|
133
|
+
enabled: true,
|
|
134
|
+
pollInterval: 5000, // 5 seconds
|
|
135
|
+
maxAttempts: 3,
|
|
136
|
+
lockTimeout: 300000, // 5 minutes
|
|
137
|
+
failOnError: false,
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// Optional: Storage paths
|
|
141
|
+
stagingPath: 'staging',
|
|
142
|
+
mediaPath: 'media',
|
|
143
|
+
originalsPath: 'originals',
|
|
144
|
+
|
|
145
|
+
// Optional: Limits
|
|
146
|
+
maxFileSize: 50 * 1024 * 1024, // 50MB
|
|
147
|
+
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## API Endpoints
|
|
152
|
+
|
|
153
|
+
### Upload Image
|
|
154
|
+
|
|
155
|
+
```http
|
|
156
|
+
POST /media/upload HTTP/1.1
|
|
157
|
+
|
|
158
|
+
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
|
|
159
|
+
|
|
160
|
+
------WebKitFormBoundary
|
|
161
|
+
Content-Disposition: form-data; name="file"; filename="photo.jpg"
|
|
162
|
+
Content-Type: image/jpeg
|
|
163
|
+
|
|
164
|
+
[binary image data]
|
|
165
|
+
------WebKitFormBoundary
|
|
166
|
+
Content-Disposition: form-data; name="sourceType"
|
|
167
|
+
|
|
168
|
+
avatar
|
|
169
|
+
------WebKitFormBoundary
|
|
170
|
+
Content-Disposition: form-data; name="sourceId"
|
|
171
|
+
|
|
172
|
+
user123
|
|
173
|
+
------WebKitFormBoundary--
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Response:** `202 Accepted`
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"jobId": "clh7k9w1j0000nv8zk9k9k9k9",
|
|
180
|
+
"message": "File uploaded. Processing started.",
|
|
181
|
+
"statusUrl": "/media/status/clh7k9w1j0000nv8zk9k9k9k9"
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Check Processing Status
|
|
186
|
+
|
|
187
|
+
```http
|
|
188
|
+
GET /media/status/:jobId HTTP/1.1
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**Responses:**
|
|
192
|
+
|
|
193
|
+
While Processing (202):
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"jobId": "clh7k9w1j0000nv8zk9k9k9k9",
|
|
197
|
+
"status": "PROCESSING",
|
|
198
|
+
"sourceType": "avatar",
|
|
199
|
+
"sourceId": "user123",
|
|
200
|
+
"createdAt": "2024-01-15T10:30:00Z",
|
|
201
|
+
"updatedAt": "2024-01-15T10:30:02Z"
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
When Complete (200):
|
|
206
|
+
```json
|
|
207
|
+
{
|
|
208
|
+
"jobId": "clh7k9w1j0000nv8zk9k9k9k9",
|
|
209
|
+
"status": "COMPLETE",
|
|
210
|
+
"sourceType": "avatar",
|
|
211
|
+
"sourceId": "user123",
|
|
212
|
+
"media": {
|
|
213
|
+
"id": "media-1705314600000-abc123",
|
|
214
|
+
"urls": {
|
|
215
|
+
"xs": "https://r2.example.com/media/avatar/user123/...-xs.webp",
|
|
216
|
+
"sm": "https://r2.example.com/media/avatar/user123/...-sm.webp",
|
|
217
|
+
"md": "https://r2.example.com/media/avatar/user123/...-md.webp"
|
|
218
|
+
},
|
|
219
|
+
"originalUrl": "https://r2.example.com/originals/avatar/user123/.../original.jpg",
|
|
220
|
+
"width": 2000,
|
|
221
|
+
"height": 2000,
|
|
222
|
+
"aspectRatio": "1:1",
|
|
223
|
+
"blurhash": "UeKUpMxua4t757oJodS3_3kCMd9F6p",
|
|
224
|
+
"focalPoint": { "x": 0.5, "y": 0.5 }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
When Rejected (400):
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"jobId": "clh7k9w1j0000nv8zk9k9k9k9",
|
|
233
|
+
"status": "REJECTED",
|
|
234
|
+
"reason": "REJECTED",
|
|
235
|
+
"moderationDetails": {
|
|
236
|
+
"flags": ["adult", "violence"],
|
|
237
|
+
"confidence": { "adult": 0.95 }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
When Failed (500):
|
|
243
|
+
```json
|
|
244
|
+
{
|
|
245
|
+
"jobId": "clh7k9w1j0000nv8zk9k9k9k9",
|
|
246
|
+
"status": "FAILED",
|
|
247
|
+
"error": "Failed to download from R2",
|
|
248
|
+
"attempts": 3
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Source Types & Variant Presets
|
|
253
|
+
|
|
254
|
+
The plugin comes with predefined source types and their variant presets:
|
|
255
|
+
|
|
256
|
+
| Source Type | Variants | Use Case |
|
|
257
|
+
|---|---|---|
|
|
258
|
+
| `avatar` | xs, sm | User/band profile pictures |
|
|
259
|
+
| `member_photo` | xs, sm, md | Member directory images |
|
|
260
|
+
| `gallery` | md, lg, xl | Gallery display images |
|
|
261
|
+
| `hero` | lg, xl, 2xl | Hero/banner backgrounds |
|
|
262
|
+
| `content` | md, lg | Article/post images |
|
|
263
|
+
|
|
264
|
+
Each source type generates only the specified variants. For example, `avatar` uploads will create `xs.webp` and `sm.webp` files.
|
|
265
|
+
|
|
266
|
+
## Frontend Usage
|
|
267
|
+
|
|
268
|
+
### React Example
|
|
269
|
+
|
|
270
|
+
```jsx
|
|
271
|
+
import { useEffect, useState } from 'react';
|
|
272
|
+
|
|
273
|
+
function AvatarUpload({ userId }) {
|
|
274
|
+
const [jobId, setJobId] = useState(null);
|
|
275
|
+
const [status, setStatus] = useState(null);
|
|
276
|
+
const [loading, setLoading] = useState(false);
|
|
277
|
+
|
|
278
|
+
const handleUpload = async (file) => {
|
|
279
|
+
const formData = new FormData();
|
|
280
|
+
formData.append('file', file);
|
|
281
|
+
formData.append('sourceType', 'avatar');
|
|
282
|
+
formData.append('sourceId', userId);
|
|
283
|
+
|
|
284
|
+
const response = await fetch('/media/upload', {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
body: formData,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const data = await response.json();
|
|
290
|
+
setJobId(data.jobId);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
if (!jobId) return;
|
|
295
|
+
|
|
296
|
+
const checkStatus = async () => {
|
|
297
|
+
const response = await fetch(`/media/status/${jobId}`);
|
|
298
|
+
const data = await response.json();
|
|
299
|
+
setStatus(data);
|
|
300
|
+
|
|
301
|
+
if (data.status === 'COMPLETE') {
|
|
302
|
+
setLoading(false);
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const interval = setInterval(checkStatus, 2000);
|
|
307
|
+
return () => clearInterval(interval);
|
|
308
|
+
}, [jobId]);
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<div>
|
|
312
|
+
<input type="file" onChange={(e) => handleUpload(e.target.files[0])} />
|
|
313
|
+
{status?.media && (
|
|
314
|
+
<img src={status.media.urls.sm} alt="Avatar" />
|
|
315
|
+
)}
|
|
316
|
+
{status?.status === 'PROCESSING' && <p>Processing...</p>}
|
|
317
|
+
{status?.status === 'REJECTED' && <p>Image rejected: {status.reason}</p>}
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Nuxt Example
|
|
324
|
+
|
|
325
|
+
```vue
|
|
326
|
+
<template>
|
|
327
|
+
<div>
|
|
328
|
+
<input type="file" @change="handleUpload" />
|
|
329
|
+
|
|
330
|
+
<div v-if="media">
|
|
331
|
+
<img :src="media.urls.sm" alt="Avatar" />
|
|
332
|
+
<div
|
|
333
|
+
v-if="media.blurhash"
|
|
334
|
+
:style="{ backgroundColor: blurhashColor }"
|
|
335
|
+
class="blur-placeholder"
|
|
336
|
+
/>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
<p v-if="status === 'PROCESSING'">Processing...</p>
|
|
340
|
+
<p v-if="status === 'REJECTED'">Image rejected</p>
|
|
341
|
+
</div>
|
|
342
|
+
</template>
|
|
343
|
+
|
|
344
|
+
<script setup>
|
|
345
|
+
import { ref } from 'vue';
|
|
346
|
+
|
|
347
|
+
const jobId = ref(null);
|
|
348
|
+
const media = ref(null);
|
|
349
|
+
const status = ref(null);
|
|
350
|
+
|
|
351
|
+
const handleUpload = async (event) => {
|
|
352
|
+
const file = event.target.files[0];
|
|
353
|
+
const formData = new FormData();
|
|
354
|
+
formData.append('file', file);
|
|
355
|
+
formData.append('sourceType', 'avatar');
|
|
356
|
+
formData.append('sourceId', 'user123');
|
|
357
|
+
|
|
358
|
+
const response = await fetch('/media/upload', {
|
|
359
|
+
method: 'POST',
|
|
360
|
+
body: formData,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const { jobId: id } = await response.json();
|
|
364
|
+
jobId.value = id;
|
|
365
|
+
pollStatus();
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const pollStatus = async () => {
|
|
369
|
+
const response = await fetch(`/media/status/${jobId.value}`);
|
|
370
|
+
const data = await response.json();
|
|
371
|
+
status.value = data.status;
|
|
372
|
+
|
|
373
|
+
if (data.status === 'COMPLETE') {
|
|
374
|
+
media.value = data.media;
|
|
375
|
+
} else if (data.status !== 'PROCESSING') {
|
|
376
|
+
setTimeout(pollStatus, 2000);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
</script>
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## Processing Pipeline
|
|
383
|
+
|
|
384
|
+
1. **Upload** - File received at `/media/upload`
|
|
385
|
+
2. **Queue** - Job created in database with `PENDING` status
|
|
386
|
+
3. **Worker Polling** - Worker finds next job and locks it
|
|
387
|
+
4. **Download** - File downloaded from staging bucket
|
|
388
|
+
5. **EXIF Strip** - Metadata removed while preserving orientation
|
|
389
|
+
6. **Moderation** - Content checked (if enabled)
|
|
390
|
+
7. **Variants** - WebP variants generated at specified sizes
|
|
391
|
+
8. **Blurhash** - Loading placeholder generated
|
|
392
|
+
9. **Upload** - Variants and original uploaded to media bucket
|
|
393
|
+
10. **Store** - Media record created with URLs and metadata
|
|
394
|
+
11. **Cleanup** - Staging file deleted
|
|
395
|
+
12. **Complete** - Job status updated to `COMPLETE`
|
|
396
|
+
|
|
397
|
+
## Configuration
|
|
398
|
+
|
|
399
|
+
### R2 Setup
|
|
400
|
+
|
|
401
|
+
1. Create R2 bucket
|
|
402
|
+
2. Generate API token
|
|
403
|
+
3. Set CORS policy on bucket:
|
|
404
|
+
```json
|
|
405
|
+
{
|
|
406
|
+
"CORSRules": [
|
|
407
|
+
{
|
|
408
|
+
"AllowedOrigins": ["https://yoursite.com"],
|
|
409
|
+
"AllowedMethods": ["GET"],
|
|
410
|
+
"AllowedHeaders": ["*"],
|
|
411
|
+
"MaxAgeSeconds": 3600
|
|
412
|
+
}
|
|
413
|
+
]
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
4. Set lifecycle policy to clean staging:
|
|
418
|
+
```
|
|
419
|
+
Delete objects in /staging/ older than 1 day
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Environment Variables
|
|
423
|
+
|
|
424
|
+
```bash
|
|
425
|
+
# R2
|
|
426
|
+
R2_ENDPOINT=https://[account-id].r2.cloudflarestorage.com
|
|
427
|
+
R2_ACCESS_KEY_ID=...
|
|
428
|
+
R2_SECRET_ACCESS_KEY=...
|
|
429
|
+
R2_BUCKET=my-media-bucket
|
|
430
|
+
|
|
431
|
+
# Database (Prisma)
|
|
432
|
+
DATABASE_URL=postgresql://...
|
|
433
|
+
|
|
434
|
+
# Moderation (optional)
|
|
435
|
+
MODERATION_PROVIDER=rekognition
|
|
436
|
+
MODERATION_API_KEY=...
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
## Testing
|
|
440
|
+
|
|
441
|
+
```bash
|
|
442
|
+
npm test
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
## Performance Tips
|
|
446
|
+
|
|
447
|
+
- ๐ **Direct Upload**: Consider presigned URLs for large files to bypass server bandwidth
|
|
448
|
+
- ๐ฏ **CDN**: Put R2 behind Cloudflare CDN for caching
|
|
449
|
+
- โก **Worker Pool**: Run multiple worker processes for faster processing
|
|
450
|
+
- ๐ **Monitoring**: Monitor `MediaQueue` table for stuck jobs
|
|
451
|
+
- ๐งน **Cleanup**: Run cleanup tasks regularly to remove orphaned files
|
|
452
|
+
|
|
453
|
+
## Troubleshooting
|
|
454
|
+
|
|
455
|
+
### Jobs stuck in PROCESSING
|
|
456
|
+
|
|
457
|
+
Jobs are automatically recovered if locked > 5 minutes. To manually recover:
|
|
458
|
+
|
|
459
|
+
```javascript
|
|
460
|
+
import { recoverStaleLocks } from '@xenterprises/fastify-xmedia/workers/processor';
|
|
461
|
+
|
|
462
|
+
await recoverStaleLocks(prisma, 5 * 60 * 1000);
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### Missing variants
|
|
466
|
+
|
|
467
|
+
Check that sourceType is in `getVariantPresets()`. Custom source types require variant config.
|
|
468
|
+
|
|
469
|
+
### R2 upload failures
|
|
470
|
+
|
|
471
|
+
- Verify credentials and bucket name
|
|
472
|
+
- Check CORS policy
|
|
473
|
+
- Ensure endpoint URL is correct
|
|
474
|
+
|
|
475
|
+
## Future Enhancements
|
|
476
|
+
|
|
477
|
+
- [ ] Direct browser-to-R2 uploads (presigned URLs)
|
|
478
|
+
- [ ] Video support with thumbnails
|
|
479
|
+
- [ ] Audio waveform generation
|
|
480
|
+
- [ ] Batch uploads
|
|
481
|
+
- [ ] AVIF format support
|
|
482
|
+
- [ ] Face detection for smart cropping
|
|
483
|
+
- [ ] Custom image filters/transforms
|
|
484
|
+
- [ ] Analytics and usage tracking
|
|
485
|
+
|
|
486
|
+
## License
|
|
487
|
+
|
|
488
|
+
ISC
|
package/SCHEMA.prisma
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// This schema should be added to your main Prisma schema.prisma file
|
|
2
|
+
// It defines the models needed for the xMedia pipeline
|
|
3
|
+
|
|
4
|
+
enum MediaStatus {
|
|
5
|
+
PENDING
|
|
6
|
+
PROCESSING
|
|
7
|
+
COMPLETE
|
|
8
|
+
REJECTED
|
|
9
|
+
FAILED
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
enum ModerationResult {
|
|
13
|
+
APPROVED
|
|
14
|
+
REJECTED
|
|
15
|
+
FLAGGED
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
model MediaQueue {
|
|
19
|
+
id String @id @default(cuid())
|
|
20
|
+
|
|
21
|
+
// Job status
|
|
22
|
+
status MediaStatus @default(PENDING)
|
|
23
|
+
|
|
24
|
+
// Source information
|
|
25
|
+
sourceType String // avatar, gallery, hero, member_photo, content, etc.
|
|
26
|
+
sourceId String // userId, bandId, etc.
|
|
27
|
+
|
|
28
|
+
// File information
|
|
29
|
+
stagingKey String // Key in R2 staging bucket
|
|
30
|
+
originalFilename String
|
|
31
|
+
mimeType String
|
|
32
|
+
fileSize Int
|
|
33
|
+
|
|
34
|
+
// Processing results
|
|
35
|
+
mediaId String? // FK to Media after processing
|
|
36
|
+
media Media? @relation(fields: [mediaId], references: [id], onDelete: SetNull)
|
|
37
|
+
|
|
38
|
+
// Error tracking
|
|
39
|
+
attempts Int @default(0)
|
|
40
|
+
maxAttempts Int @default(3)
|
|
41
|
+
errorMsg String?
|
|
42
|
+
|
|
43
|
+
// Moderation results
|
|
44
|
+
moderationResult ModerationResult?
|
|
45
|
+
moderationDetails Json? // Full moderation API response
|
|
46
|
+
|
|
47
|
+
// Locking for worker process
|
|
48
|
+
lockedAt DateTime?
|
|
49
|
+
lockedBy String? // Worker ID that locked this job
|
|
50
|
+
|
|
51
|
+
// Timestamps
|
|
52
|
+
createdAt DateTime @default(now())
|
|
53
|
+
updatedAt DateTime @updatedAt
|
|
54
|
+
|
|
55
|
+
@@index([status, createdAt])
|
|
56
|
+
@@index([sourceType, sourceId])
|
|
57
|
+
@@index([lockedAt])
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
model Media {
|
|
61
|
+
id String @id @default(cuid())
|
|
62
|
+
|
|
63
|
+
// Variant URLs (object mapping variant name to URL)
|
|
64
|
+
// e.g., { "xs": "https://...", "sm": "https://...", "md": "https://..." }
|
|
65
|
+
urls Json @default("{}")
|
|
66
|
+
originalUrl String // URL to full-resolution original
|
|
67
|
+
|
|
68
|
+
// Image properties
|
|
69
|
+
width Int
|
|
70
|
+
height Int
|
|
71
|
+
format String // jpeg, png, webp, gif
|
|
72
|
+
aspectRatio String // e.g., "16:9", "4:3", "1:1"
|
|
73
|
+
|
|
74
|
+
// Loading placeholder
|
|
75
|
+
blurhash String // For instant UI placeholder
|
|
76
|
+
|
|
77
|
+
// For smart cropping
|
|
78
|
+
focalPoint Json @default("{\"x\": 0.5, \"y\": 0.5}") // { x: 0-1, y: 0-1 }
|
|
79
|
+
|
|
80
|
+
// Source information (denormalized for queries)
|
|
81
|
+
sourceType String
|
|
82
|
+
sourceId String
|
|
83
|
+
|
|
84
|
+
// File information
|
|
85
|
+
originalFilename String
|
|
86
|
+
mimeType String
|
|
87
|
+
fileSize Int
|
|
88
|
+
|
|
89
|
+
// Metadata
|
|
90
|
+
exifStripped Boolean @default(true)
|
|
91
|
+
createdAt DateTime @default(now())
|
|
92
|
+
updatedAt DateTime @updatedAt
|
|
93
|
+
|
|
94
|
+
// Relations (optional - add as needed)
|
|
95
|
+
// User.avatar => Media
|
|
96
|
+
// Band.avatar => Media
|
|
97
|
+
// Gallery items, etc.
|
|
98
|
+
|
|
99
|
+
queue MediaQueue[]
|
|
100
|
+
|
|
101
|
+
@@index([sourceType, sourceId])
|
|
102
|
+
@@index([createdAt])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Example: Add to User model
|
|
106
|
+
// avatar Media? @relation(fields: [avatarId], references: [id], onDelete: SetNull)
|
|
107
|
+
// avatarId String?
|
|
108
|
+
|
|
109
|
+
// Example: Add to Band model
|
|
110
|
+
// avatar Media? @relation(fields: [avatarId], references: [id], onDelete: SetNull)
|
|
111
|
+
// avatarId String?
|
|
112
|
+
// hero Media? @relation(fields: [heroId], references: [id], onDelete: SetNull)
|
|
113
|
+
// heroId String?
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xenterprises/fastify-ximagepipeline",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Fastify plugin for image uploads with EXIF stripping, moderation, variant generation, and R2 storage with job queue",
|
|
6
|
+
"main": "src/xImagePipeline.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/xImagePipeline.js",
|
|
9
|
+
"./image": "./src/utils/image.js",
|
|
10
|
+
"./s3": "./src/services/s3.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "fastify start -l info server/app.js",
|
|
14
|
+
"dev": "fastify start -w -l info -P server/app.js",
|
|
15
|
+
"test": "node --test test/xImagePipeline.test.js"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"fastify",
|
|
19
|
+
"media",
|
|
20
|
+
"image",
|
|
21
|
+
"upload",
|
|
22
|
+
"s3",
|
|
23
|
+
"r2",
|
|
24
|
+
"cloudflare",
|
|
25
|
+
"exif",
|
|
26
|
+
"variants",
|
|
27
|
+
"moderation",
|
|
28
|
+
"sharp",
|
|
29
|
+
"webp"
|
|
30
|
+
],
|
|
31
|
+
"author": "Tim Mushen",
|
|
32
|
+
"license": "ISC",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git@gitlab.com:x-enterprises/fastify-plugins/fastify-x-imagepipeline.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://gitlab.com/x-enterprises/fastify-plugins/fastify-x-imagepipeline/-/issues"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@aws-sdk/client-s3": "^3.700.0",
|
|
42
|
+
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
|
43
|
+
"blurhash": "^2.0.5",
|
|
44
|
+
"fastify-plugin": "^5.0.0",
|
|
45
|
+
"sharp": "^0.33.5"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@fastify/multipart": "^9.0.0",
|
|
49
|
+
"@types/node": "^22.7.4",
|
|
50
|
+
"fastify": "^5.1.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"fastify": "^5.0.0"
|
|
54
|
+
}
|
|
55
|
+
}
|