@xenterprises/fastify-ximagepipeline 1.1.0 → 1.2.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/LICENSE +60 -0
- package/README.md +211 -441
- package/package.json +2 -2
- package/src/routes/status.js +7 -9
- package/src/routes/upload.js +12 -17
- package/src/utils/image.js +71 -85
- package/src/workers/processor.js +39 -30
- package/src/xImagePipeline.js +93 -22
- package/FILES.md +0 -212
- package/IMPLEMENTATION_SUMMARY.md +0 -427
- package/INTEGRATION_GUIDE.md +0 -554
- package/SCHEMA.prisma +0 -113
- package/test/xImagePipeline.test.js +0 -196
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! 🎉
|
package/SCHEMA.prisma
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
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?
|