@xenterprises/fastify-ximagepipeline 1.1.1 → 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/SCHEMA.prisma +0 -113
package/README.md
CHANGED
|
@@ -1,488 +1,258 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @xenterprises/fastify-ximagepipeline
|
|
2
2
|
|
|
3
|
-
Fastify plugin for
|
|
3
|
+
Fastify plugin for image uploads with EXIF stripping, variant generation, blurhash placeholders, and R2/S3 storage — powered by a background job queue.
|
|
4
4
|
|
|
5
|
-
##
|
|
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
|
|
5
|
+
## Install
|
|
19
6
|
|
|
20
7
|
```bash
|
|
21
|
-
npm install @xenterprises/fastify-
|
|
8
|
+
npm install @xenterprises/fastify-ximagepipeline
|
|
22
9
|
```
|
|
23
10
|
|
|
24
|
-
|
|
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
|
-
}
|
|
11
|
+
**Peer dependencies:** `fastify ^5.0.0`, `@fastify/multipart` (register before this plugin)
|
|
63
12
|
|
|
64
|
-
|
|
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
|
-
```
|
|
13
|
+
## Quick Start
|
|
87
14
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
import
|
|
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();
|
|
15
|
+
```js
|
|
16
|
+
import Fastify from "fastify";
|
|
17
|
+
import multipart from "@fastify/multipart";
|
|
18
|
+
import xImagePipeline from "@xenterprises/fastify-ximagepipeline";
|
|
98
19
|
|
|
20
|
+
const fastify = Fastify({ logger: true });
|
|
99
21
|
await fastify.register(multipart);
|
|
100
|
-
|
|
101
|
-
await fastify.register(xMedia, {
|
|
102
|
-
// R2 Configuration
|
|
22
|
+
await fastify.register(xImagePipeline, {
|
|
103
23
|
r2: {
|
|
104
24
|
endpoint: process.env.R2_ENDPOINT,
|
|
105
|
-
region: 'auto',
|
|
106
25
|
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
|
107
26
|
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
|
|
108
27
|
bucket: process.env.R2_BUCKET,
|
|
109
28
|
},
|
|
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'],
|
|
29
|
+
db: prisma, // Prisma client with MediaQueue + Media models
|
|
148
30
|
});
|
|
149
|
-
```
|
|
150
31
|
|
|
151
|
-
|
|
32
|
+
await fastify.listen({ port: 3000 });
|
|
33
|
+
```
|
|
152
34
|
|
|
153
|
-
|
|
35
|
+
Upload an image:
|
|
154
36
|
|
|
155
|
-
```
|
|
156
|
-
|
|
37
|
+
```bash
|
|
38
|
+
curl -F "file=@photo.jpg" \
|
|
39
|
+
-F "sourceType=avatar" \
|
|
40
|
+
-F "sourceId=user-123" \
|
|
41
|
+
http://localhost:3000/image-pipeline/upload
|
|
42
|
+
```
|
|
157
43
|
|
|
158
|
-
|
|
44
|
+
## Plugin Options
|
|
45
|
+
|
|
46
|
+
| Option | Type | Default | Required | Description |
|
|
47
|
+
|--------|------|---------|----------|-------------|
|
|
48
|
+
| `r2` | `Object` | — | Yes | R2/S3 connection config (see below) |
|
|
49
|
+
| `db` | `Object` | — | Yes | Prisma client with `mediaQueue` and `media` models |
|
|
50
|
+
| `moderation` | `Object` | `null` | No | Content moderation config with `handler` function |
|
|
51
|
+
| `variants` | `Object` | 6 sizes | No | Variant dimension specs (xs/sm/md/lg/xl/2xl) |
|
|
52
|
+
| `sourceTypes` | `Object` | 5 types | No | Per-source-type processing config |
|
|
53
|
+
| `worker` | `Object` | enabled | No | Background worker settings |
|
|
54
|
+
| `stagingPath` | `string` | `"staging"` | No | R2 prefix for staging uploads |
|
|
55
|
+
| `mediaPath` | `string` | `"media"` | No | R2 prefix for processed media |
|
|
56
|
+
| `originalsPath` | `string` | `"originals"` | No | R2 prefix for original files |
|
|
57
|
+
| `maxFileSize` | `number` | `52428800` | No | Max upload size in bytes (50MB) |
|
|
58
|
+
| `allowedMimeTypes` | `string[]` | jpeg/png/webp/gif | No | Accepted MIME types |
|
|
59
|
+
|
|
60
|
+
### `r2` Config
|
|
61
|
+
|
|
62
|
+
| Field | Type | Required | Description |
|
|
63
|
+
|-------|------|----------|-------------|
|
|
64
|
+
| `endpoint` | `string` | Yes | R2 or S3-compatible endpoint URL |
|
|
65
|
+
| `accessKeyId` | `string` | Yes | Access key |
|
|
66
|
+
| `secretAccessKey` | `string` | Yes | Secret key |
|
|
67
|
+
| `bucket` | `string` | Yes | Bucket name |
|
|
68
|
+
| `region` | `string` | No | Region (default `"auto"` for R2) |
|
|
69
|
+
|
|
70
|
+
### `worker` Config
|
|
71
|
+
|
|
72
|
+
| Field | Type | Default | Description |
|
|
73
|
+
|-------|------|---------|-------------|
|
|
74
|
+
| `enabled` | `boolean` | `true` | Enable/disable background processing |
|
|
75
|
+
| `pollInterval` | `number` | `5000` | Poll interval in ms |
|
|
76
|
+
| `maxAttempts` | `number` | `3` | Max retry attempts per job |
|
|
77
|
+
| `lockTimeout` | `number` | `300000` | Lock timeout in ms (5 min) |
|
|
78
|
+
| `failOnError` | `boolean` | `true` | Throw if worker fails to start |
|
|
79
|
+
|
|
80
|
+
### `moderation` Config
|
|
81
|
+
|
|
82
|
+
| Field | Type | Required | Description |
|
|
83
|
+
|-------|------|----------|-------------|
|
|
84
|
+
| `handler` | `function` | No | `async (buffer, config) => { passed, flags, confidence }` |
|
|
85
|
+
|
|
86
|
+
If no handler is provided, all images are approved. Provide a handler to call AWS Rekognition, Google Vision, or any moderation API.
|
|
87
|
+
|
|
88
|
+
## Default Variants
|
|
89
|
+
|
|
90
|
+
| Name | Width | Height | Fit | Use Case |
|
|
91
|
+
|------|-------|--------|-----|----------|
|
|
92
|
+
| `xs` | 80 | 80 | cover | Tiny thumbnails, avatars |
|
|
93
|
+
| `sm` | 200 | 200 | cover | Thumbnails, lists |
|
|
94
|
+
| `md` | 600 | auto | inside | Content images, cards |
|
|
95
|
+
| `lg` | 1200 | auto | inside | Detail views |
|
|
96
|
+
| `xl` | 1920 | auto | inside | Full-width banners |
|
|
97
|
+
| `2xl` | 2560 | auto | inside | Retina/4K displays |
|
|
98
|
+
|
|
99
|
+
## Default Source Types
|
|
100
|
+
|
|
101
|
+
| Type | Variants | Quality | Store Original |
|
|
102
|
+
|------|----------|---------|----------------|
|
|
103
|
+
| `avatar` | xs, sm | 85 | Yes |
|
|
104
|
+
| `member_photo` | xs, sm, md | 85 | Yes |
|
|
105
|
+
| `gallery` | md, lg, xl | 85 | No |
|
|
106
|
+
| `hero` | lg, xl, 2xl | 80 | No |
|
|
107
|
+
| `content` | md, lg | 85 | Yes |
|
|
159
108
|
|
|
160
|
-
|
|
161
|
-
Content-Disposition: form-data; name="file"; filename="photo.jpg"
|
|
162
|
-
Content-Type: image/jpeg
|
|
109
|
+
## API Endpoints
|
|
163
110
|
|
|
164
|
-
|
|
165
|
-
------WebKitFormBoundary
|
|
166
|
-
Content-Disposition: form-data; name="sourceType"
|
|
111
|
+
### `POST /image-pipeline/upload`
|
|
167
112
|
|
|
168
|
-
|
|
169
|
-
------WebKitFormBoundary
|
|
170
|
-
Content-Disposition: form-data; name="sourceId"
|
|
113
|
+
Upload an image for processing. Requires multipart form data.
|
|
171
114
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
115
|
+
**Fields:**
|
|
116
|
+
- `file` (binary, required) — image file
|
|
117
|
+
- `sourceType` (string, required) — one of the configured source types
|
|
118
|
+
- `sourceId` (string, required) — identifier for the source entity
|
|
175
119
|
|
|
176
|
-
**Response
|
|
120
|
+
**Response (202 Accepted):**
|
|
177
121
|
```json
|
|
178
122
|
{
|
|
179
|
-
"jobId": "
|
|
123
|
+
"jobId": "clx1234...",
|
|
180
124
|
"message": "File uploaded. Processing started.",
|
|
181
|
-
"statusUrl": "/
|
|
125
|
+
"statusUrl": "/image-pipeline/status/clx1234..."
|
|
182
126
|
}
|
|
183
127
|
```
|
|
184
128
|
|
|
185
|
-
###
|
|
129
|
+
### `GET /image-pipeline/status/:jobId`
|
|
186
130
|
|
|
187
|
-
|
|
188
|
-
GET /media/status/:jobId HTTP/1.1
|
|
189
|
-
```
|
|
131
|
+
Check job processing status.
|
|
190
132
|
|
|
191
|
-
|
|
133
|
+
| Status | HTTP Code | Extra Fields |
|
|
134
|
+
|--------|-----------|--------------|
|
|
135
|
+
| PENDING | 202 | — |
|
|
136
|
+
| PROCESSING | 202 | — |
|
|
137
|
+
| COMPLETE | 200 | `media` object with urls, blurhash, dimensions |
|
|
138
|
+
| REJECTED | 400 | `reason`, `moderationDetails` |
|
|
139
|
+
| FAILED | 500 | `error`, `attempts` |
|
|
192
140
|
|
|
193
|
-
|
|
141
|
+
**Complete response example:**
|
|
194
142
|
```json
|
|
195
143
|
{
|
|
196
|
-
"jobId": "
|
|
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",
|
|
144
|
+
"jobId": "clx1234...",
|
|
209
145
|
"status": "COMPLETE",
|
|
210
|
-
"sourceType": "avatar",
|
|
211
|
-
"sourceId": "user123",
|
|
212
146
|
"media": {
|
|
213
|
-
"id": "media-
|
|
214
|
-
"urls": {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
"
|
|
220
|
-
"width": 2000,
|
|
221
|
-
"height": 2000,
|
|
222
|
-
"aspectRatio": "1:1",
|
|
223
|
-
"blurhash": "UeKUpMxua4t757oJodS3_3kCMd9F6p",
|
|
147
|
+
"id": "media-abc...",
|
|
148
|
+
"urls": { "xs": "https://cdn.../xs.webp", "sm": "https://cdn.../sm.webp" },
|
|
149
|
+
"originalUrl": "https://cdn.../original.jpg",
|
|
150
|
+
"width": 1920,
|
|
151
|
+
"height": 1080,
|
|
152
|
+
"aspectRatio": "16:9",
|
|
153
|
+
"blurhash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
|
|
224
154
|
"focalPoint": { "x": 0.5, "y": 0.5 }
|
|
225
155
|
}
|
|
226
156
|
}
|
|
227
157
|
```
|
|
228
158
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
|
261
|
-
|
|
262
|
-
| `
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
159
|
+
## Decorated Properties
|
|
160
|
+
|
|
161
|
+
The plugin decorates `fastify.xImagePipeline` with:
|
|
162
|
+
|
|
163
|
+
| Method | Signature | Description |
|
|
164
|
+
|--------|-----------|-------------|
|
|
165
|
+
| `getStatus` | `(jobId: string) => Promise<Object\|null>` | Get job with media relation |
|
|
166
|
+
| `deleteMedia` | `(mediaId: string) => Promise<{ deleted, r2Deleted }>` | Delete media + R2 objects |
|
|
167
|
+
| `listMedia` | `(sourceType, sourceId) => Promise<Object[]>` | List media by source |
|
|
168
|
+
| `getVariantPresets` | `() => Object` | Get source type → variant name mapping |
|
|
169
|
+
| `getSourceTypes` | `() => Object` | Get source type configurations |
|
|
170
|
+
| `getVariants` | `() => Object` | Get variant dimension specs |
|
|
171
|
+
|
|
172
|
+
## Exported Utilities
|
|
173
|
+
|
|
174
|
+
### `@xenterprises/fastify-ximagepipeline/image`
|
|
175
|
+
|
|
176
|
+
| Function | Description |
|
|
177
|
+
|----------|-------------|
|
|
178
|
+
| `stripExif(buffer)` | Remove EXIF metadata, preserve orientation |
|
|
179
|
+
| `getImageMetadata(buffer)` | Extract width, height, format, colorspace, hasAlpha, density |
|
|
180
|
+
| `compressToJpeg(buffer, quality?)` | Compress to JPEG (mozjpeg, default quality 85) |
|
|
181
|
+
| `generateVariants(buffer, specs, sourceType, quality?)` | Generate WebP variants |
|
|
182
|
+
| `generateBlurhash(buffer)` | Create 4x3 component blurhash |
|
|
183
|
+
| `calculateFitDimensions(srcW, srcH, maxW, maxH)` | Aspect-ratio-preserving resize calc |
|
|
184
|
+
| `getAspectRatio(width, height)` | Return ratio string like "16:9" |
|
|
185
|
+
| `validateImage(buffer, options?)` | Validate dimensions and format |
|
|
186
|
+
| `processImage(buffer, sourceType, config)` | Full pipeline: strip, metadata, variants, blurhash |
|
|
187
|
+
|
|
188
|
+
### `@xenterprises/fastify-ximagepipeline/s3`
|
|
189
|
+
|
|
190
|
+
| Function | Description |
|
|
191
|
+
|----------|-------------|
|
|
192
|
+
| `initializeS3Client(config)` | Create S3Client for R2/AWS |
|
|
193
|
+
| `uploadToS3(client, bucket, key, buffer, options?)` | Upload with metadata + cache headers |
|
|
194
|
+
| `downloadFromS3(client, bucket, key)` | Download buffer |
|
|
195
|
+
| `deleteFromS3(client, bucket, key)` | Delete single object |
|
|
196
|
+
| `listFromS3(client, bucket, prefix)` | List objects by prefix |
|
|
197
|
+
| `getSignedUrlForS3(client, bucket, key, expiresIn?)` | Generate signed URL (default 1h) |
|
|
198
|
+
| `getPublicUrl(r2Config, key)` | Generate public URL |
|
|
199
|
+
| `batchDeleteFromS3(client, bucket, prefix)` | Delete up to 1000 objects by prefix |
|
|
200
|
+
|
|
201
|
+
## Environment Variables
|
|
202
|
+
|
|
203
|
+
| Variable | Required | Description |
|
|
204
|
+
|----------|----------|-------------|
|
|
205
|
+
| `R2_ENDPOINT` | Yes | R2/S3-compatible endpoint URL |
|
|
206
|
+
| `R2_ACCESS_KEY_ID` | Yes | Storage access key |
|
|
207
|
+
| `R2_SECRET_ACCESS_KEY` | Yes | Storage secret key |
|
|
208
|
+
| `R2_BUCKET` | Yes | Storage bucket name |
|
|
209
|
+
| `DATABASE_URL` | Yes | Prisma database connection string |
|
|
210
|
+
|
|
211
|
+
## Error Reference
|
|
212
|
+
|
|
213
|
+
All errors are prefixed with `[xImagePipeline]`.
|
|
214
|
+
|
|
215
|
+
| Error | When |
|
|
216
|
+
|-------|------|
|
|
217
|
+
| `R2 configuration is required` | Missing `r2` option |
|
|
218
|
+
| `Database instance (Prisma client) is required` | Missing `db` option |
|
|
219
|
+
| `R2 configuration must include: endpoint, accessKeyId, secretAccessKey, bucket` | Incomplete R2 config |
|
|
220
|
+
| `No file provided` | Upload request has no file |
|
|
221
|
+
| `File type {mime} not allowed` | Upload MIME type not in allowedMimeTypes |
|
|
222
|
+
| `sourceType and sourceId are required` | Missing form fields on upload |
|
|
223
|
+
| `Unknown sourceType: {type}` | sourceType not in variant presets |
|
|
224
|
+
| `File too large. Maximum size: {n}MB` | File exceeds maxFileSize |
|
|
225
|
+
| `Failed to upload file to storage` | R2 upload error |
|
|
226
|
+
| `Failed to create processing job` | Database error during job creation |
|
|
227
|
+
| `Media not found: {id}` | deleteMedia called with invalid ID |
|
|
228
|
+
|
|
229
|
+
## Database Schema
|
|
230
|
+
|
|
231
|
+
The plugin requires two Prisma models. See `SCHEMA.prisma` in the package for the full schema.
|
|
232
|
+
|
|
233
|
+
**MediaQueue** — job queue for async processing (PENDING → PROCESSING → COMPLETE/REJECTED/FAILED)
|
|
234
|
+
|
|
235
|
+
**Media** — processed media records with variant URLs, dimensions, blurhash, and metadata
|
|
236
|
+
|
|
237
|
+
## How It Works
|
|
238
|
+
|
|
239
|
+
1. **Upload**: Client sends multipart POST with image + sourceType + sourceId. The file is validated (type, size) and uploaded to R2 staging. A `MediaQueue` job is created with PENDING status.
|
|
240
|
+
|
|
241
|
+
2. **Processing**: A background worker polls for PENDING jobs using pessimistic locking (prevents duplicate processing). For each job:
|
|
242
|
+
- Downloads from staging
|
|
243
|
+
- Strips EXIF metadata (preserves orientation)
|
|
244
|
+
- Extracts dimensions and format
|
|
245
|
+
- Runs content moderation (if configured)
|
|
246
|
+
- Generates WebP variants per sourceType config
|
|
247
|
+
- Generates blurhash placeholder
|
|
248
|
+
- Uploads variants (and optional original) to R2
|
|
249
|
+
- Creates `Media` record with URLs and metadata
|
|
250
|
+
- Marks job COMPLETE and cleans up staging
|
|
251
|
+
|
|
252
|
+
3. **Retrieval**: Client polls the status endpoint. On COMPLETE, the response includes all variant URLs, blurhash, and dimensions for immediate frontend use.
|
|
253
|
+
|
|
254
|
+
4. **Retry**: Failed jobs are retried up to `maxAttempts`. Stale locks (worker crashed) are automatically recovered after `lockTimeout`.
|
|
485
255
|
|
|
486
256
|
## License
|
|
487
257
|
|
|
488
|
-
|
|
258
|
+
UNLICENSED
|