@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
|
@@ -0,0 +1,554 @@
|
|
|
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! 🎉
|