@uploadista/flow-images-sharp 0.0.20-beta.7 → 0.0.20-beta.9
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/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -9
- package/src/image-plugin.ts +675 -674
package/src/image-plugin.ts
CHANGED
|
@@ -60,384 +60,385 @@ const calculateOverlayPosition = (
|
|
|
60
60
|
return { top, left };
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
export const imagePlugin =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
"image
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const sharpFit = mapFitToSharp(fit);
|
|
95
|
-
const outputBytes = yield* Effect.tryPromise({
|
|
96
|
-
try: async () =>
|
|
97
|
-
await sharp(inputBytes)
|
|
98
|
-
.resize(width, height, { fit: sharpFit })
|
|
99
|
-
.toBuffer(),
|
|
100
|
-
catch: (error) => {
|
|
101
|
-
return UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
102
|
-
cause: error,
|
|
103
|
-
});
|
|
104
|
-
},
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
return new Uint8Array(outputBytes);
|
|
108
|
-
}).pipe(
|
|
109
|
-
withOperationSpan("image", "resize", {
|
|
110
|
-
"image.width": width,
|
|
111
|
-
"image.height": height,
|
|
112
|
-
"image.fit": fit,
|
|
113
|
-
"image.input_size": inputBytes.byteLength,
|
|
114
|
-
}),
|
|
115
|
-
);
|
|
116
|
-
},
|
|
117
|
-
transform: (inputBytes, transformation) => {
|
|
118
|
-
return Effect.gen(function* () {
|
|
119
|
-
let pipeline = sharp(inputBytes);
|
|
120
|
-
|
|
121
|
-
switch (transformation.type) {
|
|
122
|
-
case "resize": {
|
|
123
|
-
const sharpFit = mapFitToSharp(transformation.fit);
|
|
124
|
-
pipeline = pipeline.resize(
|
|
125
|
-
transformation.width,
|
|
126
|
-
transformation.height,
|
|
127
|
-
{
|
|
128
|
-
fit: sharpFit,
|
|
129
|
-
},
|
|
63
|
+
export const imagePlugin = () =>
|
|
64
|
+
Layer.succeed(
|
|
65
|
+
ImagePlugin,
|
|
66
|
+
ImagePlugin.of({
|
|
67
|
+
optimize: (inputBytes, { quality, format }) => {
|
|
68
|
+
return Effect.gen(function* () {
|
|
69
|
+
const outputBytes = yield* Effect.tryPromise({
|
|
70
|
+
try: async () =>
|
|
71
|
+
await sharp(inputBytes).toFormat(format, { quality }).toBuffer(),
|
|
72
|
+
catch: (error) => {
|
|
73
|
+
return UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
74
|
+
cause: error,
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
return new Uint8Array(outputBytes);
|
|
79
|
+
}).pipe(
|
|
80
|
+
withOperationSpan("image", "optimize", {
|
|
81
|
+
"image.format": format,
|
|
82
|
+
"image.quality": quality,
|
|
83
|
+
"image.input_size": inputBytes.byteLength,
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
resize: (inputBytes, { width, height, fit }) => {
|
|
88
|
+
return Effect.gen(function* () {
|
|
89
|
+
if (!width && !height) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
"Either width or height must be specified for resize",
|
|
130
92
|
);
|
|
131
|
-
break;
|
|
132
93
|
}
|
|
133
94
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
95
|
+
const sharpFit = mapFitToSharp(fit);
|
|
96
|
+
const outputBytes = yield* Effect.tryPromise({
|
|
97
|
+
try: async () =>
|
|
98
|
+
await sharp(inputBytes)
|
|
99
|
+
.resize(width, height, { fit: sharpFit })
|
|
100
|
+
.toBuffer(),
|
|
101
|
+
catch: (error) => {
|
|
102
|
+
return UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
103
|
+
cause: error,
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
});
|
|
146
107
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
108
|
+
return new Uint8Array(outputBytes);
|
|
109
|
+
}).pipe(
|
|
110
|
+
withOperationSpan("image", "resize", {
|
|
111
|
+
"image.width": width,
|
|
112
|
+
"image.height": height,
|
|
113
|
+
"image.fit": fit,
|
|
114
|
+
"image.input_size": inputBytes.byteLength,
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
transform: (inputBytes, transformation) => {
|
|
119
|
+
return Effect.gen(function* () {
|
|
120
|
+
let pipeline = sharp(inputBytes);
|
|
121
|
+
|
|
122
|
+
switch (transformation.type) {
|
|
123
|
+
case "resize": {
|
|
124
|
+
const sharpFit = mapFitToSharp(transformation.fit);
|
|
125
|
+
pipeline = pipeline.resize(
|
|
126
|
+
transformation.width,
|
|
127
|
+
transformation.height,
|
|
128
|
+
{
|
|
129
|
+
fit: sharpFit,
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
break;
|
|
152
133
|
}
|
|
153
|
-
break;
|
|
154
|
-
}
|
|
155
134
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
135
|
+
case "blur": {
|
|
136
|
+
pipeline = pipeline.blur(transformation.sigma);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
160
139
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
140
|
+
case "rotate": {
|
|
141
|
+
const options = transformation.background
|
|
142
|
+
? { background: transformation.background }
|
|
143
|
+
: undefined;
|
|
144
|
+
pipeline = pipeline.rotate(transformation.angle, options);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
166
147
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
148
|
+
case "flip": {
|
|
149
|
+
if (transformation.direction === "horizontal") {
|
|
150
|
+
pipeline = pipeline.flop();
|
|
151
|
+
} else {
|
|
152
|
+
pipeline = pipeline.flip();
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
173
156
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
break;
|
|
179
|
-
}
|
|
157
|
+
case "grayscale": {
|
|
158
|
+
pipeline = pipeline.grayscale();
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
180
161
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
pipeline = pipeline.
|
|
184
|
-
|
|
185
|
-
pipeline = pipeline.sharpen();
|
|
162
|
+
case "sepia": {
|
|
163
|
+
// Apply sepia tone using tint
|
|
164
|
+
pipeline = pipeline.tint({ r: 112, g: 66, b: 20 });
|
|
165
|
+
break;
|
|
186
166
|
}
|
|
187
|
-
break;
|
|
188
|
-
}
|
|
189
167
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
throw new Error(
|
|
197
|
-
`Failed to fetch watermark: ${response.statusText}`,
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
201
|
-
return Buffer.from(arrayBuffer);
|
|
202
|
-
},
|
|
203
|
-
catch: (error) => {
|
|
204
|
-
return UploadistaError.fromCode("FILE_NOT_FOUND", {
|
|
205
|
-
body: `Watermark image not found or failed to fetch: ${transformation.imagePath}`,
|
|
206
|
-
cause: error,
|
|
207
|
-
});
|
|
208
|
-
},
|
|
209
|
-
}).pipe(
|
|
210
|
-
withOperationSpan("image", "fetch-watermark", {
|
|
211
|
-
"image.watermark_url": transformation.imagePath,
|
|
212
|
-
}),
|
|
213
|
-
);
|
|
168
|
+
case "brightness": {
|
|
169
|
+
// Convert -100 to +100 range to multiplier (0 to 2)
|
|
170
|
+
const multiplier = 1 + transformation.value / 100;
|
|
171
|
+
pipeline = pipeline.modulate({ brightness: multiplier });
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
214
174
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
cause: error,
|
|
222
|
-
});
|
|
223
|
-
},
|
|
224
|
-
});
|
|
175
|
+
case "contrast": {
|
|
176
|
+
// Convert -100 to +100 range to linear adjustment
|
|
177
|
+
const a = 1 + transformation.value / 100;
|
|
178
|
+
pipeline = pipeline.linear(a, 0);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
225
181
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
},
|
|
235
|
-
});
|
|
182
|
+
case "sharpen": {
|
|
183
|
+
if (transformation.sigma !== undefined) {
|
|
184
|
+
pipeline = pipeline.sharpen({ sigma: transformation.sigma });
|
|
185
|
+
} else {
|
|
186
|
+
pipeline = pipeline.sharpen();
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
236
190
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
191
|
+
case "watermark": {
|
|
192
|
+
// Fetch watermark image from URL
|
|
193
|
+
const watermarkBuffer = yield* Effect.tryPromise({
|
|
194
|
+
try: async () => {
|
|
195
|
+
const response = await fetch(transformation.imagePath);
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Failed to fetch watermark: ${response.statusText}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
202
|
+
return Buffer.from(arrayBuffer);
|
|
203
|
+
},
|
|
204
|
+
catch: (error) => {
|
|
205
|
+
return UploadistaError.fromCode("FILE_NOT_FOUND", {
|
|
206
|
+
body: `Watermark image not found or failed to fetch: ${transformation.imagePath}`,
|
|
207
|
+
cause: error,
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
}).pipe(
|
|
211
|
+
withOperationSpan("image", "fetch-watermark", {
|
|
212
|
+
"image.watermark_url": transformation.imagePath,
|
|
246
213
|
}),
|
|
247
214
|
);
|
|
248
|
-
}
|
|
249
215
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
216
|
+
// Get image metadata to calculate positioning
|
|
217
|
+
const metadata = yield* Effect.tryPromise({
|
|
218
|
+
try: async () => await pipeline.metadata(),
|
|
219
|
+
catch: (error) => {
|
|
220
|
+
return UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
221
|
+
body: "Failed to read image metadata",
|
|
222
|
+
cause: error,
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Get watermark metadata
|
|
228
|
+
const watermarkMetadata = yield* Effect.tryPromise({
|
|
229
|
+
try: async () => await sharp(watermarkBuffer).metadata(),
|
|
230
|
+
catch: (error) => {
|
|
231
|
+
return UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
232
|
+
body: "Failed to read watermark metadata",
|
|
233
|
+
cause: error,
|
|
234
|
+
});
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (
|
|
239
|
+
!metadata.width ||
|
|
240
|
+
!metadata.height ||
|
|
241
|
+
!watermarkMetadata.width ||
|
|
242
|
+
!watermarkMetadata.height
|
|
243
|
+
) {
|
|
244
|
+
return yield* Effect.fail(
|
|
245
|
+
UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
246
|
+
body: "Could not determine image or watermark dimensions",
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const { top, left } = calculateOverlayPosition(
|
|
252
|
+
transformation.position,
|
|
253
|
+
metadata.width,
|
|
254
|
+
metadata.height,
|
|
255
|
+
watermarkMetadata.width,
|
|
256
|
+
watermarkMetadata.height,
|
|
257
|
+
transformation.offsetX,
|
|
258
|
+
transformation.offsetY,
|
|
259
|
+
);
|
|
259
260
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
261
|
+
// Apply watermark with opacity
|
|
262
|
+
const watermarkWithOpacity = yield* Effect.tryPromise({
|
|
263
|
+
try: async () =>
|
|
264
|
+
await sharp(watermarkBuffer)
|
|
265
|
+
.composite([
|
|
266
|
+
{
|
|
267
|
+
input: Buffer.from([
|
|
268
|
+
255,
|
|
269
|
+
255,
|
|
270
|
+
255,
|
|
271
|
+
Math.round(transformation.opacity * 255),
|
|
272
|
+
]),
|
|
273
|
+
raw: {
|
|
274
|
+
width: 1,
|
|
275
|
+
height: 1,
|
|
276
|
+
channels: 4,
|
|
277
|
+
},
|
|
278
|
+
tile: true,
|
|
279
|
+
blend: "dest-in",
|
|
276
280
|
},
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
]);
|
|
297
|
-
break;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
case "logo": {
|
|
301
|
-
// Fetch logo image from URL
|
|
302
|
-
const logoBuffer = yield* Effect.tryPromise({
|
|
303
|
-
try: async () => {
|
|
304
|
-
const response = await fetch(transformation.imagePath);
|
|
305
|
-
if (!response.ok) {
|
|
306
|
-
throw new Error(
|
|
307
|
-
`Failed to fetch logo: ${response.statusText}`,
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
311
|
-
return Buffer.from(arrayBuffer);
|
|
312
|
-
},
|
|
313
|
-
catch: (error) => {
|
|
314
|
-
return UploadistaError.fromCode("FILE_NOT_FOUND", {
|
|
315
|
-
body: `Logo image not found or failed to fetch: ${transformation.imagePath}`,
|
|
316
|
-
cause: error,
|
|
317
|
-
});
|
|
318
|
-
},
|
|
319
|
-
}).pipe(
|
|
320
|
-
withOperationSpan("image", "fetch-logo", {
|
|
321
|
-
"image.logo_url": transformation.imagePath,
|
|
322
|
-
}),
|
|
323
|
-
);
|
|
324
|
-
|
|
325
|
-
// Get image metadata
|
|
326
|
-
const metadata = yield* Effect.tryPromise({
|
|
327
|
-
try: async () => await pipeline.metadata(),
|
|
328
|
-
catch: (error) => {
|
|
329
|
-
return UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
330
|
-
body: "Failed to read image metadata",
|
|
331
|
-
cause: error,
|
|
332
|
-
});
|
|
333
|
-
},
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
// Get logo metadata
|
|
337
|
-
const logoMetadata = yield* Effect.tryPromise({
|
|
338
|
-
try: async () => await sharp(logoBuffer).metadata(),
|
|
339
|
-
catch: (error) => {
|
|
340
|
-
return UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
341
|
-
body: "Failed to read logo metadata",
|
|
342
|
-
cause: error,
|
|
343
|
-
});
|
|
344
|
-
},
|
|
345
|
-
});
|
|
281
|
+
])
|
|
282
|
+
.toBuffer(),
|
|
283
|
+
catch: (error) => {
|
|
284
|
+
return UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
285
|
+
body: "Failed to apply watermark opacity",
|
|
286
|
+
cause: error,
|
|
287
|
+
});
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
pipeline = pipeline.composite([
|
|
292
|
+
{
|
|
293
|
+
input: watermarkWithOpacity,
|
|
294
|
+
top,
|
|
295
|
+
left,
|
|
296
|
+
},
|
|
297
|
+
]);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
346
300
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
301
|
+
case "logo": {
|
|
302
|
+
// Fetch logo image from URL
|
|
303
|
+
const logoBuffer = yield* Effect.tryPromise({
|
|
304
|
+
try: async () => {
|
|
305
|
+
const response = await fetch(transformation.imagePath);
|
|
306
|
+
if (!response.ok) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`Failed to fetch logo: ${response.statusText}`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
312
|
+
return Buffer.from(arrayBuffer);
|
|
313
|
+
},
|
|
314
|
+
catch: (error) => {
|
|
315
|
+
return UploadistaError.fromCode("FILE_NOT_FOUND", {
|
|
316
|
+
body: `Logo image not found or failed to fetch: ${transformation.imagePath}`,
|
|
317
|
+
cause: error,
|
|
318
|
+
});
|
|
319
|
+
},
|
|
320
|
+
}).pipe(
|
|
321
|
+
withOperationSpan("image", "fetch-logo", {
|
|
322
|
+
"image.logo_url": transformation.imagePath,
|
|
356
323
|
}),
|
|
357
324
|
);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Scale logo
|
|
361
|
-
const scaledLogoWidth = Math.round(
|
|
362
|
-
logoMetadata.width * transformation.scale,
|
|
363
|
-
);
|
|
364
|
-
const scaledLogoHeight = Math.round(
|
|
365
|
-
logoMetadata.height * transformation.scale,
|
|
366
|
-
);
|
|
367
|
-
|
|
368
|
-
const scaledLogo = yield* Effect.tryPromise({
|
|
369
|
-
try: async () =>
|
|
370
|
-
await sharp(logoBuffer)
|
|
371
|
-
.resize(scaledLogoWidth, scaledLogoHeight)
|
|
372
|
-
.toBuffer(),
|
|
373
|
-
catch: (error) => {
|
|
374
|
-
return UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
375
|
-
body: "Failed to scale logo",
|
|
376
|
-
cause: error,
|
|
377
|
-
});
|
|
378
|
-
},
|
|
379
|
-
});
|
|
380
325
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
326
|
+
// Get image metadata
|
|
327
|
+
const metadata = yield* Effect.tryPromise({
|
|
328
|
+
try: async () => await pipeline.metadata(),
|
|
329
|
+
catch: (error) => {
|
|
330
|
+
return UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
331
|
+
body: "Failed to read image metadata",
|
|
332
|
+
cause: error,
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Get logo metadata
|
|
338
|
+
const logoMetadata = yield* Effect.tryPromise({
|
|
339
|
+
try: async () => await sharp(logoBuffer).metadata(),
|
|
340
|
+
catch: (error) => {
|
|
341
|
+
return UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
342
|
+
body: "Failed to read logo metadata",
|
|
343
|
+
cause: error,
|
|
344
|
+
});
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (
|
|
349
|
+
!metadata.width ||
|
|
350
|
+
!metadata.height ||
|
|
351
|
+
!logoMetadata.width ||
|
|
352
|
+
!logoMetadata.height
|
|
353
|
+
) {
|
|
354
|
+
return yield* Effect.fail(
|
|
355
|
+
UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
356
|
+
body: "Could not determine image or logo dimensions",
|
|
357
|
+
}),
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Scale logo
|
|
362
|
+
const scaledLogoWidth = Math.round(
|
|
363
|
+
logoMetadata.width * transformation.scale,
|
|
364
|
+
);
|
|
365
|
+
const scaledLogoHeight = Math.round(
|
|
366
|
+
logoMetadata.height * transformation.scale,
|
|
367
|
+
);
|
|
412
368
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
369
|
+
const scaledLogo = yield* Effect.tryPromise({
|
|
370
|
+
try: async () =>
|
|
371
|
+
await sharp(logoBuffer)
|
|
372
|
+
.resize(scaledLogoWidth, scaledLogoHeight)
|
|
373
|
+
.toBuffer(),
|
|
374
|
+
catch: (error) => {
|
|
375
|
+
return UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
376
|
+
body: "Failed to scale logo",
|
|
377
|
+
cause: error,
|
|
378
|
+
});
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const { top, left } = calculateOverlayPosition(
|
|
383
|
+
transformation.position,
|
|
384
|
+
metadata.width,
|
|
385
|
+
metadata.height,
|
|
386
|
+
scaledLogoWidth,
|
|
387
|
+
scaledLogoHeight,
|
|
388
|
+
transformation.offsetX,
|
|
389
|
+
transformation.offsetY,
|
|
418
390
|
);
|
|
391
|
+
|
|
392
|
+
pipeline = pipeline.composite([
|
|
393
|
+
{
|
|
394
|
+
input: scaledLogo,
|
|
395
|
+
top,
|
|
396
|
+
left,
|
|
397
|
+
},
|
|
398
|
+
]);
|
|
399
|
+
break;
|
|
419
400
|
}
|
|
420
401
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
402
|
+
case "text": {
|
|
403
|
+
// Get image metadata
|
|
404
|
+
const metadata = yield* Effect.tryPromise({
|
|
405
|
+
try: async () => await pipeline.metadata(),
|
|
406
|
+
catch: (error) => {
|
|
407
|
+
return UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
408
|
+
body: "Failed to read image metadata",
|
|
409
|
+
cause: error,
|
|
410
|
+
});
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
if (!metadata.width || !metadata.height) {
|
|
415
|
+
return yield* Effect.fail(
|
|
416
|
+
UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
417
|
+
body: "Could not determine image dimensions",
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Create SVG text overlay
|
|
423
|
+
const fontFamily = transformation.fontFamily || "sans-serif";
|
|
424
|
+
|
|
425
|
+
// Estimate text dimensions (rough approximation)
|
|
426
|
+
const textWidth =
|
|
427
|
+
transformation.text.length * transformation.fontSize * 0.6;
|
|
428
|
+
const textHeight = transformation.fontSize;
|
|
429
|
+
|
|
430
|
+
const { top, left } = calculateOverlayPosition(
|
|
431
|
+
transformation.position,
|
|
432
|
+
metadata.width,
|
|
433
|
+
metadata.height,
|
|
434
|
+
textWidth,
|
|
435
|
+
textHeight,
|
|
436
|
+
transformation.offsetX,
|
|
437
|
+
transformation.offsetY,
|
|
438
|
+
);
|
|
438
439
|
|
|
439
|
-
|
|
440
|
-
|
|
440
|
+
// Create positioned SVG
|
|
441
|
+
const positionedSvg = `
|
|
441
442
|
<svg width="${metadata.width}" height="${metadata.height}">
|
|
442
443
|
<text
|
|
443
444
|
x="${left}"
|
|
@@ -449,366 +450,366 @@ export const imagePlugin = Layer.succeed(
|
|
|
449
450
|
</svg>
|
|
450
451
|
`;
|
|
451
452
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
453
|
+
pipeline = pipeline.composite([
|
|
454
|
+
{
|
|
455
|
+
input: Buffer.from(positionedSvg),
|
|
456
|
+
top: 0,
|
|
457
|
+
left: 0,
|
|
458
|
+
},
|
|
459
|
+
]);
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
461
462
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
463
|
+
default: {
|
|
464
|
+
// TypeScript should ensure this is unreachable
|
|
465
|
+
return yield* Effect.fail(
|
|
466
|
+
UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
467
|
+
body: `Unsupported transformation type: ${(transformation as { type: string }).type}`,
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
469
471
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
"image.input_size": inputBytes.byteLength,
|
|
488
|
-
}),
|
|
489
|
-
);
|
|
490
|
-
},
|
|
491
|
-
|
|
492
|
-
/**
|
|
493
|
-
* Indicates that this plugin supports streaming operations.
|
|
494
|
-
*/
|
|
495
|
-
supportsStreaming: true,
|
|
496
|
-
|
|
497
|
-
/**
|
|
498
|
-
* Streaming optimization using Sharp's pipeline.
|
|
499
|
-
*
|
|
500
|
-
* Collects input stream chunks, processes through Sharp, and returns
|
|
501
|
-
* the result as a stream. This avoids double-buffering when combined
|
|
502
|
-
* with streaming DataStore reads.
|
|
503
|
-
*/
|
|
504
|
-
optimizeStream: (
|
|
505
|
-
inputStream: Stream.Stream<Uint8Array, UploadistaError>,
|
|
506
|
-
{ quality, format }: OptimizeParams,
|
|
507
|
-
): Effect.Effect<
|
|
508
|
-
Stream.Stream<Uint8Array, UploadistaError>,
|
|
509
|
-
UploadistaError
|
|
510
|
-
> => {
|
|
511
|
-
return Effect.gen(function* () {
|
|
512
|
-
// Collect input stream to buffer (Sharp needs full image to decode)
|
|
513
|
-
const chunks: Uint8Array[] = [];
|
|
514
|
-
yield* Stream.runForEach(inputStream, (chunk) =>
|
|
515
|
-
Effect.sync(() => {
|
|
516
|
-
chunks.push(chunk);
|
|
472
|
+
|
|
473
|
+
// Convert pipeline to buffer
|
|
474
|
+
const outputBytes = yield* Effect.tryPromise({
|
|
475
|
+
try: async () => await pipeline.toBuffer(),
|
|
476
|
+
catch: (error) => {
|
|
477
|
+
return UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
478
|
+
body: `Failed to apply transformation: ${transformation.type}`,
|
|
479
|
+
cause: error,
|
|
480
|
+
});
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
return new Uint8Array(outputBytes);
|
|
485
|
+
}).pipe(
|
|
486
|
+
withOperationSpan("image", "transform", {
|
|
487
|
+
"image.transformation_type": transformation.type,
|
|
488
|
+
"image.input_size": inputBytes.byteLength,
|
|
517
489
|
}),
|
|
518
490
|
);
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Indicates that this plugin supports streaming operations.
|
|
495
|
+
*/
|
|
496
|
+
supportsStreaming: true,
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Streaming optimization using Sharp's pipeline.
|
|
500
|
+
*
|
|
501
|
+
* Collects input stream chunks, processes through Sharp, and returns
|
|
502
|
+
* the result as a stream. This avoids double-buffering when combined
|
|
503
|
+
* with streaming DataStore reads.
|
|
504
|
+
*/
|
|
505
|
+
optimizeStream: (
|
|
506
|
+
inputStream: Stream.Stream<Uint8Array, UploadistaError>,
|
|
507
|
+
{ quality, format }: OptimizeParams,
|
|
508
|
+
): Effect.Effect<
|
|
509
|
+
Stream.Stream<Uint8Array, UploadistaError>,
|
|
510
|
+
UploadistaError
|
|
511
|
+
> => {
|
|
512
|
+
return Effect.gen(function* () {
|
|
513
|
+
// Collect input stream to buffer (Sharp needs full image to decode)
|
|
514
|
+
const chunks: Uint8Array[] = [];
|
|
515
|
+
yield* Stream.runForEach(inputStream, (chunk) =>
|
|
516
|
+
Effect.sync(() => {
|
|
517
|
+
chunks.push(chunk);
|
|
518
|
+
}),
|
|
519
|
+
);
|
|
519
520
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
// Process through Sharp and output as stream
|
|
530
|
-
return Stream.async<Uint8Array, UploadistaError>((emit) => {
|
|
531
|
-
const sharpInstance = sharp(inputBuffer).toFormat(format, {
|
|
532
|
-
quality,
|
|
533
|
-
});
|
|
521
|
+
// Combine chunks
|
|
522
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
|
|
523
|
+
const inputBuffer = new Uint8Array(totalLength);
|
|
524
|
+
let offset = 0;
|
|
525
|
+
for (const chunk of chunks) {
|
|
526
|
+
inputBuffer.set(chunk, offset);
|
|
527
|
+
offset += chunk.byteLength;
|
|
528
|
+
}
|
|
534
529
|
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
sharpInstance
|
|
540
|
-
.pipe(outputStream)
|
|
541
|
-
.on("data", (chunk: Buffer) => {
|
|
542
|
-
outputChunks.push(chunk);
|
|
543
|
-
})
|
|
544
|
-
.on("end", () => {
|
|
545
|
-
// Emit all collected chunks as a single Uint8Array
|
|
546
|
-
const outputBuffer = Buffer.concat(outputChunks);
|
|
547
|
-
emit.single(new Uint8Array(outputBuffer));
|
|
548
|
-
emit.end();
|
|
549
|
-
})
|
|
550
|
-
.on("error", (error: Error) => {
|
|
551
|
-
emit.fail(
|
|
552
|
-
UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
553
|
-
body: `Sharp streaming optimization failed: ${error.message}`,
|
|
554
|
-
cause: error,
|
|
555
|
-
}),
|
|
556
|
-
);
|
|
530
|
+
// Process through Sharp and output as stream
|
|
531
|
+
return Stream.async<Uint8Array, UploadistaError>((emit) => {
|
|
532
|
+
const sharpInstance = sharp(inputBuffer).toFormat(format, {
|
|
533
|
+
quality,
|
|
557
534
|
});
|
|
558
535
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
536
|
+
// Use Sharp's streaming output
|
|
537
|
+
const outputStream = new PassThrough();
|
|
538
|
+
const outputChunks: Buffer[] = [];
|
|
539
|
+
|
|
540
|
+
sharpInstance
|
|
541
|
+
.pipe(outputStream)
|
|
542
|
+
.on("data", (chunk: Buffer) => {
|
|
543
|
+
outputChunks.push(chunk);
|
|
544
|
+
})
|
|
545
|
+
.on("end", () => {
|
|
546
|
+
// Emit all collected chunks as a single Uint8Array
|
|
547
|
+
const outputBuffer = Buffer.concat(outputChunks);
|
|
548
|
+
emit.single(new Uint8Array(outputBuffer));
|
|
549
|
+
emit.end();
|
|
550
|
+
})
|
|
551
|
+
.on("error", (error: Error) => {
|
|
552
|
+
emit.fail(
|
|
553
|
+
UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
554
|
+
body: `Sharp streaming optimization failed: ${error.message}`,
|
|
555
|
+
cause: error,
|
|
556
|
+
}),
|
|
557
|
+
);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Cleanup
|
|
561
|
+
return Effect.sync(() => {
|
|
562
|
+
outputStream.destroy();
|
|
563
|
+
});
|
|
562
564
|
});
|
|
563
|
-
})
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
565
|
+
}).pipe(
|
|
566
|
+
withOperationSpan("image", "optimize-stream", {
|
|
567
|
+
"image.format": format,
|
|
568
|
+
"image.quality": quality,
|
|
569
|
+
}),
|
|
570
|
+
);
|
|
571
|
+
},
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Streaming resize using Sharp's pipeline.
|
|
575
|
+
*
|
|
576
|
+
* Collects input stream chunks, processes through Sharp's resize,
|
|
577
|
+
* and returns the result as a stream.
|
|
578
|
+
*/
|
|
579
|
+
resizeStream: (
|
|
580
|
+
inputStream: Stream.Stream<Uint8Array, UploadistaError>,
|
|
581
|
+
{ width, height, fit }: ResizeParams,
|
|
582
|
+
): Effect.Effect<
|
|
583
|
+
Stream.Stream<Uint8Array, UploadistaError>,
|
|
584
|
+
UploadistaError
|
|
585
|
+
> => {
|
|
586
|
+
return Effect.gen(function* () {
|
|
587
|
+
if (!width && !height) {
|
|
588
|
+
return yield* Effect.fail(
|
|
589
|
+
UploadistaError.fromCode("VALIDATION_ERROR", {
|
|
590
|
+
body: "Either width or height must be specified for resize",
|
|
591
|
+
}),
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Collect input stream to buffer (Sharp needs full image to decode)
|
|
596
|
+
const chunks: Uint8Array[] = [];
|
|
597
|
+
yield* Stream.runForEach(inputStream, (chunk) =>
|
|
598
|
+
Effect.sync(() => {
|
|
599
|
+
chunks.push(chunk);
|
|
590
600
|
}),
|
|
591
601
|
);
|
|
592
|
-
}
|
|
593
602
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
603
|
+
// Combine chunks
|
|
604
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
|
|
605
|
+
const inputBuffer = new Uint8Array(totalLength);
|
|
606
|
+
let offset = 0;
|
|
607
|
+
for (const chunk of chunks) {
|
|
608
|
+
inputBuffer.set(chunk, offset);
|
|
609
|
+
offset += chunk.byteLength;
|
|
610
|
+
}
|
|
601
611
|
|
|
602
|
-
|
|
603
|
-
const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
|
|
604
|
-
const inputBuffer = new Uint8Array(totalLength);
|
|
605
|
-
let offset = 0;
|
|
606
|
-
for (const chunk of chunks) {
|
|
607
|
-
inputBuffer.set(chunk, offset);
|
|
608
|
-
offset += chunk.byteLength;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const sharpFit = mapFitToSharp(fit);
|
|
612
|
-
|
|
613
|
-
// Process through Sharp and output as stream
|
|
614
|
-
return Stream.async<Uint8Array, UploadistaError>((emit) => {
|
|
615
|
-
const sharpInstance = sharp(inputBuffer).resize(width, height, {
|
|
616
|
-
fit: sharpFit,
|
|
617
|
-
});
|
|
612
|
+
const sharpFit = mapFitToSharp(fit);
|
|
618
613
|
|
|
619
|
-
//
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
sharpInstance
|
|
624
|
-
.pipe(outputStream)
|
|
625
|
-
.on("data", (chunk: Buffer) => {
|
|
626
|
-
outputChunks.push(chunk);
|
|
627
|
-
})
|
|
628
|
-
.on("end", () => {
|
|
629
|
-
// Emit all collected chunks as a single Uint8Array
|
|
630
|
-
const outputBuffer = Buffer.concat(outputChunks);
|
|
631
|
-
emit.single(new Uint8Array(outputBuffer));
|
|
632
|
-
emit.end();
|
|
633
|
-
})
|
|
634
|
-
.on("error", (error: Error) => {
|
|
635
|
-
emit.fail(
|
|
636
|
-
UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
637
|
-
body: `Sharp streaming resize failed: ${error.message}`,
|
|
638
|
-
cause: error,
|
|
639
|
-
}),
|
|
640
|
-
);
|
|
614
|
+
// Process through Sharp and output as stream
|
|
615
|
+
return Stream.async<Uint8Array, UploadistaError>((emit) => {
|
|
616
|
+
const sharpInstance = sharp(inputBuffer).resize(width, height, {
|
|
617
|
+
fit: sharpFit,
|
|
641
618
|
});
|
|
642
619
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
620
|
+
// Use Sharp's streaming output
|
|
621
|
+
const outputStream = new PassThrough();
|
|
622
|
+
const outputChunks: Buffer[] = [];
|
|
623
|
+
|
|
624
|
+
sharpInstance
|
|
625
|
+
.pipe(outputStream)
|
|
626
|
+
.on("data", (chunk: Buffer) => {
|
|
627
|
+
outputChunks.push(chunk);
|
|
628
|
+
})
|
|
629
|
+
.on("end", () => {
|
|
630
|
+
// Emit all collected chunks as a single Uint8Array
|
|
631
|
+
const outputBuffer = Buffer.concat(outputChunks);
|
|
632
|
+
emit.single(new Uint8Array(outputBuffer));
|
|
633
|
+
emit.end();
|
|
634
|
+
})
|
|
635
|
+
.on("error", (error: Error) => {
|
|
636
|
+
emit.fail(
|
|
637
|
+
UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
638
|
+
body: `Sharp streaming resize failed: ${error.message}`,
|
|
639
|
+
cause: error,
|
|
640
|
+
}),
|
|
641
|
+
);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// Cleanup
|
|
645
|
+
return Effect.sync(() => {
|
|
646
|
+
outputStream.destroy();
|
|
647
|
+
});
|
|
646
648
|
});
|
|
647
|
-
})
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
"image.fit": fit,
|
|
653
|
-
}),
|
|
654
|
-
);
|
|
655
|
-
},
|
|
656
|
-
|
|
657
|
-
/**
|
|
658
|
-
* Streaming transformation using Sharp's pipeline.
|
|
659
|
-
*
|
|
660
|
-
* Collects input stream chunks, applies the transformation,
|
|
661
|
-
* and returns the result as a stream.
|
|
662
|
-
*/
|
|
663
|
-
transformStream: (
|
|
664
|
-
inputStream: Stream.Stream<Uint8Array, UploadistaError>,
|
|
665
|
-
transformation: Transformation,
|
|
666
|
-
): Effect.Effect<
|
|
667
|
-
Stream.Stream<Uint8Array, UploadistaError>,
|
|
668
|
-
UploadistaError
|
|
669
|
-
> => {
|
|
670
|
-
return Effect.gen(function* () {
|
|
671
|
-
// Collect input stream to buffer (Sharp needs full image to decode)
|
|
672
|
-
const chunks: Uint8Array[] = [];
|
|
673
|
-
yield* Stream.runForEach(inputStream, (chunk) =>
|
|
674
|
-
Effect.sync(() => {
|
|
675
|
-
chunks.push(chunk);
|
|
649
|
+
}).pipe(
|
|
650
|
+
withOperationSpan("image", "resize-stream", {
|
|
651
|
+
"image.width": width,
|
|
652
|
+
"image.height": height,
|
|
653
|
+
"image.fit": fit,
|
|
676
654
|
}),
|
|
677
655
|
);
|
|
656
|
+
},
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Streaming transformation using Sharp's pipeline.
|
|
660
|
+
*
|
|
661
|
+
* Collects input stream chunks, applies the transformation,
|
|
662
|
+
* and returns the result as a stream.
|
|
663
|
+
*/
|
|
664
|
+
transformStream: (
|
|
665
|
+
inputStream: Stream.Stream<Uint8Array, UploadistaError>,
|
|
666
|
+
transformation: Transformation,
|
|
667
|
+
): Effect.Effect<
|
|
668
|
+
Stream.Stream<Uint8Array, UploadistaError>,
|
|
669
|
+
UploadistaError
|
|
670
|
+
> => {
|
|
671
|
+
return Effect.gen(function* () {
|
|
672
|
+
// Collect input stream to buffer (Sharp needs full image to decode)
|
|
673
|
+
const chunks: Uint8Array[] = [];
|
|
674
|
+
yield* Stream.runForEach(inputStream, (chunk) =>
|
|
675
|
+
Effect.sync(() => {
|
|
676
|
+
chunks.push(chunk);
|
|
677
|
+
}),
|
|
678
|
+
);
|
|
678
679
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// Apply transformation (reuse buffered transform logic)
|
|
689
|
-
let pipeline = sharp(inputBuffer);
|
|
690
|
-
|
|
691
|
-
switch (transformation.type) {
|
|
692
|
-
case "resize": {
|
|
693
|
-
const sharpFit = mapFitToSharp(transformation.fit);
|
|
694
|
-
pipeline = pipeline.resize(
|
|
695
|
-
transformation.width,
|
|
696
|
-
transformation.height,
|
|
697
|
-
{ fit: sharpFit },
|
|
698
|
-
);
|
|
699
|
-
break;
|
|
680
|
+
// Combine chunks
|
|
681
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
|
|
682
|
+
const inputBuffer = new Uint8Array(totalLength);
|
|
683
|
+
let offset = 0;
|
|
684
|
+
for (const chunk of chunks) {
|
|
685
|
+
inputBuffer.set(chunk, offset);
|
|
686
|
+
offset += chunk.byteLength;
|
|
700
687
|
}
|
|
701
688
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
break;
|
|
705
|
-
}
|
|
689
|
+
// Apply transformation (reuse buffered transform logic)
|
|
690
|
+
let pipeline = sharp(inputBuffer);
|
|
706
691
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
692
|
+
switch (transformation.type) {
|
|
693
|
+
case "resize": {
|
|
694
|
+
const sharpFit = mapFitToSharp(transformation.fit);
|
|
695
|
+
pipeline = pipeline.resize(
|
|
696
|
+
transformation.width,
|
|
697
|
+
transformation.height,
|
|
698
|
+
{ fit: sharpFit },
|
|
699
|
+
);
|
|
700
|
+
break;
|
|
701
|
+
}
|
|
714
702
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
} else {
|
|
719
|
-
pipeline = pipeline.flip();
|
|
703
|
+
case "blur": {
|
|
704
|
+
pipeline = pipeline.blur(transformation.sigma);
|
|
705
|
+
break;
|
|
720
706
|
}
|
|
721
|
-
break;
|
|
722
|
-
}
|
|
723
707
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
708
|
+
case "rotate": {
|
|
709
|
+
const options = transformation.background
|
|
710
|
+
? { background: transformation.background }
|
|
711
|
+
: undefined;
|
|
712
|
+
pipeline = pipeline.rotate(transformation.angle, options);
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
728
715
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
716
|
+
case "flip": {
|
|
717
|
+
if (transformation.direction === "horizontal") {
|
|
718
|
+
pipeline = pipeline.flop();
|
|
719
|
+
} else {
|
|
720
|
+
pipeline = pipeline.flip();
|
|
721
|
+
}
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
733
724
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
725
|
+
case "grayscale": {
|
|
726
|
+
pipeline = pipeline.grayscale();
|
|
727
|
+
break;
|
|
728
|
+
}
|
|
739
729
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
}
|
|
730
|
+
case "sepia": {
|
|
731
|
+
pipeline = pipeline.tint({ r: 112, g: 66, b: 20 });
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
745
734
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
pipeline = pipeline.
|
|
749
|
-
|
|
750
|
-
pipeline = pipeline.sharpen();
|
|
735
|
+
case "brightness": {
|
|
736
|
+
const multiplier = 1 + transformation.value / 100;
|
|
737
|
+
pipeline = pipeline.modulate({ brightness: multiplier });
|
|
738
|
+
break;
|
|
751
739
|
}
|
|
752
|
-
break;
|
|
753
|
-
}
|
|
754
740
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
return yield* Effect.fail(
|
|
761
|
-
UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
762
|
-
body: `Streaming not supported for ${transformation.type} transformation. Use buffered mode.`,
|
|
763
|
-
}),
|
|
764
|
-
);
|
|
765
|
-
}
|
|
741
|
+
case "contrast": {
|
|
742
|
+
const a = 1 + transformation.value / 100;
|
|
743
|
+
pipeline = pipeline.linear(a, 0);
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
766
746
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
pipeline
|
|
783
|
-
.pipe(outputStream)
|
|
784
|
-
.on("data", (chunk: Buffer) => {
|
|
785
|
-
outputChunks.push(chunk);
|
|
786
|
-
})
|
|
787
|
-
.on("end", () => {
|
|
788
|
-
// Emit all collected chunks as a single Uint8Array
|
|
789
|
-
const outputBuffer = Buffer.concat(outputChunks);
|
|
790
|
-
emit.single(new Uint8Array(outputBuffer));
|
|
791
|
-
emit.end();
|
|
792
|
-
})
|
|
793
|
-
.on("error", (error: Error) => {
|
|
794
|
-
emit.fail(
|
|
747
|
+
case "sharpen": {
|
|
748
|
+
if (transformation.sigma !== undefined) {
|
|
749
|
+
pipeline = pipeline.sharpen({ sigma: transformation.sigma });
|
|
750
|
+
} else {
|
|
751
|
+
pipeline = pipeline.sharpen();
|
|
752
|
+
}
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
case "watermark":
|
|
757
|
+
case "logo":
|
|
758
|
+
case "text": {
|
|
759
|
+
// These transformations require async operations and metadata lookups
|
|
760
|
+
// Fall back to the buffered transform for these complex cases
|
|
761
|
+
return yield* Effect.fail(
|
|
795
762
|
UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
796
|
-
body: `
|
|
797
|
-
cause: error,
|
|
763
|
+
body: `Streaming not supported for ${transformation.type} transformation. Use buffered mode.`,
|
|
798
764
|
}),
|
|
799
765
|
);
|
|
800
|
-
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
default: {
|
|
769
|
+
return yield* Effect.fail(
|
|
770
|
+
UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
771
|
+
body: `Unsupported transformation type: ${(transformation as { type: string }).type}`,
|
|
772
|
+
}),
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
801
776
|
|
|
802
|
-
//
|
|
803
|
-
return
|
|
804
|
-
|
|
777
|
+
// Process through Sharp and output as stream
|
|
778
|
+
return Stream.async<Uint8Array, UploadistaError>((emit) => {
|
|
779
|
+
// Use Sharp's streaming output
|
|
780
|
+
const outputStream = new PassThrough();
|
|
781
|
+
const outputChunks: Buffer[] = [];
|
|
782
|
+
|
|
783
|
+
pipeline
|
|
784
|
+
.pipe(outputStream)
|
|
785
|
+
.on("data", (chunk: Buffer) => {
|
|
786
|
+
outputChunks.push(chunk);
|
|
787
|
+
})
|
|
788
|
+
.on("end", () => {
|
|
789
|
+
// Emit all collected chunks as a single Uint8Array
|
|
790
|
+
const outputBuffer = Buffer.concat(outputChunks);
|
|
791
|
+
emit.single(new Uint8Array(outputBuffer));
|
|
792
|
+
emit.end();
|
|
793
|
+
})
|
|
794
|
+
.on("error", (error: Error) => {
|
|
795
|
+
emit.fail(
|
|
796
|
+
UploadistaError.fromCode("UNKNOWN_ERROR", {
|
|
797
|
+
body: `Sharp streaming transform failed: ${error.message}`,
|
|
798
|
+
cause: error,
|
|
799
|
+
}),
|
|
800
|
+
);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
// Cleanup
|
|
804
|
+
return Effect.sync(() => {
|
|
805
|
+
outputStream.destroy();
|
|
806
|
+
});
|
|
805
807
|
});
|
|
806
|
-
})
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
},
|
|
813
|
-
|
|
814
|
-
);
|
|
808
|
+
}).pipe(
|
|
809
|
+
withOperationSpan("image", "transform-stream", {
|
|
810
|
+
"image.transformation_type": transformation.type,
|
|
811
|
+
}),
|
|
812
|
+
);
|
|
813
|
+
},
|
|
814
|
+
}),
|
|
815
|
+
);
|