@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.
@@ -60,384 +60,385 @@ const calculateOverlayPosition = (
60
60
  return { top, left };
61
61
  };
62
62
 
63
- export const imagePlugin = Layer.succeed(
64
- ImagePlugin,
65
- ImagePlugin.of({
66
- optimize: (inputBytes, { quality, format }) => {
67
- return Effect.gen(function* () {
68
- const outputBytes = yield* Effect.tryPromise({
69
- try: async () =>
70
- await sharp(inputBytes).toFormat(format, { quality }).toBuffer(),
71
- catch: (error) => {
72
- return UploadistaError.fromCode("UNKNOWN_ERROR", {
73
- cause: error,
74
- });
75
- },
76
- });
77
- return new Uint8Array(outputBytes);
78
- }).pipe(
79
- withOperationSpan("image", "optimize", {
80
- "image.format": format,
81
- "image.quality": quality,
82
- "image.input_size": inputBytes.byteLength,
83
- }),
84
- );
85
- },
86
- resize: (inputBytes, { width, height, fit }) => {
87
- return Effect.gen(function* () {
88
- if (!width && !height) {
89
- throw new Error(
90
- "Either width or height must be specified for resize",
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
- case "blur": {
135
- pipeline = pipeline.blur(transformation.sigma);
136
- break;
137
- }
138
-
139
- case "rotate": {
140
- const options = transformation.background
141
- ? { background: transformation.background }
142
- : undefined;
143
- pipeline = pipeline.rotate(transformation.angle, options);
144
- break;
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
- case "flip": {
148
- if (transformation.direction === "horizontal") {
149
- pipeline = pipeline.flop();
150
- } else {
151
- pipeline = pipeline.flip();
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
- case "grayscale": {
157
- pipeline = pipeline.grayscale();
158
- break;
159
- }
135
+ case "blur": {
136
+ pipeline = pipeline.blur(transformation.sigma);
137
+ break;
138
+ }
160
139
 
161
- case "sepia": {
162
- // Apply sepia tone using tint
163
- pipeline = pipeline.tint({ r: 112, g: 66, b: 20 });
164
- break;
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
- case "brightness": {
168
- // Convert -100 to +100 range to multiplier (0 to 2)
169
- const multiplier = 1 + transformation.value / 100;
170
- pipeline = pipeline.modulate({ brightness: multiplier });
171
- break;
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
- case "contrast": {
175
- // Convert -100 to +100 range to linear adjustment
176
- const a = 1 + transformation.value / 100;
177
- pipeline = pipeline.linear(a, 0);
178
- break;
179
- }
157
+ case "grayscale": {
158
+ pipeline = pipeline.grayscale();
159
+ break;
160
+ }
180
161
 
181
- case "sharpen": {
182
- if (transformation.sigma !== undefined) {
183
- pipeline = pipeline.sharpen({ sigma: transformation.sigma });
184
- } else {
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
- case "watermark": {
191
- // Fetch watermark image from URL
192
- const watermarkBuffer = yield* Effect.tryPromise({
193
- try: async () => {
194
- const response = await fetch(transformation.imagePath);
195
- if (!response.ok) {
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
- // Get image metadata to calculate positioning
216
- const metadata = yield* Effect.tryPromise({
217
- try: async () => await pipeline.metadata(),
218
- catch: (error) => {
219
- return UploadistaError.fromCode("UNKNOWN_ERROR", {
220
- body: "Failed to read image metadata",
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
- // Get watermark metadata
227
- const watermarkMetadata = yield* Effect.tryPromise({
228
- try: async () => await sharp(watermarkBuffer).metadata(),
229
- catch: (error) => {
230
- return UploadistaError.fromCode("UNKNOWN_ERROR", {
231
- body: "Failed to read watermark metadata",
232
- cause: error,
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
- if (
238
- !metadata.width ||
239
- !metadata.height ||
240
- !watermarkMetadata.width ||
241
- !watermarkMetadata.height
242
- ) {
243
- return yield* Effect.fail(
244
- UploadistaError.fromCode("UNKNOWN_ERROR", {
245
- body: "Could not determine image or watermark dimensions",
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
- const { top, left } = calculateOverlayPosition(
251
- transformation.position,
252
- metadata.width,
253
- metadata.height,
254
- watermarkMetadata.width,
255
- watermarkMetadata.height,
256
- transformation.offsetX,
257
- transformation.offsetY,
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
- // Apply watermark with opacity
261
- const watermarkWithOpacity = yield* Effect.tryPromise({
262
- try: async () =>
263
- await sharp(watermarkBuffer)
264
- .composite([
265
- {
266
- input: Buffer.from([
267
- 255,
268
- 255,
269
- 255,
270
- Math.round(transformation.opacity * 255),
271
- ]),
272
- raw: {
273
- width: 1,
274
- height: 1,
275
- channels: 4,
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
- tile: true,
278
- blend: "dest-in",
279
- },
280
- ])
281
- .toBuffer(),
282
- catch: (error) => {
283
- return UploadistaError.fromCode("UNKNOWN_ERROR", {
284
- body: "Failed to apply watermark opacity",
285
- cause: error,
286
- });
287
- },
288
- });
289
-
290
- pipeline = pipeline.composite([
291
- {
292
- input: watermarkWithOpacity,
293
- top,
294
- left,
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
- if (
348
- !metadata.width ||
349
- !metadata.height ||
350
- !logoMetadata.width ||
351
- !logoMetadata.height
352
- ) {
353
- return yield* Effect.fail(
354
- UploadistaError.fromCode("UNKNOWN_ERROR", {
355
- body: "Could not determine image or logo dimensions",
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
- const { top, left } = calculateOverlayPosition(
382
- transformation.position,
383
- metadata.width,
384
- metadata.height,
385
- scaledLogoWidth,
386
- scaledLogoHeight,
387
- transformation.offsetX,
388
- transformation.offsetY,
389
- );
390
-
391
- pipeline = pipeline.composite([
392
- {
393
- input: scaledLogo,
394
- top,
395
- left,
396
- },
397
- ]);
398
- break;
399
- }
400
-
401
- case "text": {
402
- // Get image metadata
403
- const metadata = yield* Effect.tryPromise({
404
- try: async () => await pipeline.metadata(),
405
- catch: (error) => {
406
- return UploadistaError.fromCode("UNKNOWN_ERROR", {
407
- body: "Failed to read image metadata",
408
- cause: error,
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
- if (!metadata.width || !metadata.height) {
414
- return yield* Effect.fail(
415
- UploadistaError.fromCode("UNKNOWN_ERROR", {
416
- body: "Could not determine image dimensions",
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
- // Create SVG text overlay
422
- const fontFamily = transformation.fontFamily || "sans-serif";
423
-
424
- // Estimate text dimensions (rough approximation)
425
- const textWidth =
426
- transformation.text.length * transformation.fontSize * 0.6;
427
- const textHeight = transformation.fontSize;
428
-
429
- const { top, left } = calculateOverlayPosition(
430
- transformation.position,
431
- metadata.width,
432
- metadata.height,
433
- textWidth,
434
- textHeight,
435
- transformation.offsetX,
436
- transformation.offsetY,
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
- // Create positioned SVG
440
- const positionedSvg = `
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
- pipeline = pipeline.composite([
453
- {
454
- input: Buffer.from(positionedSvg),
455
- top: 0,
456
- left: 0,
457
- },
458
- ]);
459
- break;
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
- default: {
463
- // TypeScript should ensure this is unreachable
464
- return yield* Effect.fail(
465
- UploadistaError.fromCode("UNKNOWN_ERROR", {
466
- body: `Unsupported transformation type: ${(transformation as { type: string }).type}`,
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
- // Convert pipeline to buffer
473
- const outputBytes = yield* Effect.tryPromise({
474
- try: async () => await pipeline.toBuffer(),
475
- catch: (error) => {
476
- return UploadistaError.fromCode("UNKNOWN_ERROR", {
477
- body: `Failed to apply transformation: ${transformation.type}`,
478
- cause: error,
479
- });
480
- },
481
- });
482
-
483
- return new Uint8Array(outputBytes);
484
- }).pipe(
485
- withOperationSpan("image", "transform", {
486
- "image.transformation_type": transformation.type,
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
- // Combine chunks
521
- const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
522
- const inputBuffer = new Uint8Array(totalLength);
523
- let offset = 0;
524
- for (const chunk of chunks) {
525
- inputBuffer.set(chunk, offset);
526
- offset += chunk.byteLength;
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
- // Use Sharp's streaming output
536
- const outputStream = new PassThrough();
537
- const outputChunks: Buffer[] = [];
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
- // Cleanup
560
- return Effect.sync(() => {
561
- outputStream.destroy();
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
- }).pipe(
565
- withOperationSpan("image", "optimize-stream", {
566
- "image.format": format,
567
- "image.quality": quality,
568
- }),
569
- );
570
- },
571
-
572
- /**
573
- * Streaming resize using Sharp's pipeline.
574
- *
575
- * Collects input stream chunks, processes through Sharp's resize,
576
- * and returns the result as a stream.
577
- */
578
- resizeStream: (
579
- inputStream: Stream.Stream<Uint8Array, UploadistaError>,
580
- { width, height, fit }: ResizeParams,
581
- ): Effect.Effect<
582
- Stream.Stream<Uint8Array, UploadistaError>,
583
- UploadistaError
584
- > => {
585
- return Effect.gen(function* () {
586
- if (!width && !height) {
587
- return yield* Effect.fail(
588
- UploadistaError.fromCode("VALIDATION_ERROR", {
589
- body: "Either width or height must be specified for resize",
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
- // Collect input stream to buffer (Sharp needs full image to decode)
595
- const chunks: Uint8Array[] = [];
596
- yield* Stream.runForEach(inputStream, (chunk) =>
597
- Effect.sync(() => {
598
- chunks.push(chunk);
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
- // Combine chunks
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
- // Use Sharp's streaming output
620
- const outputStream = new PassThrough();
621
- const outputChunks: Buffer[] = [];
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
- // Cleanup
644
- return Effect.sync(() => {
645
- outputStream.destroy();
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
- }).pipe(
649
- withOperationSpan("image", "resize-stream", {
650
- "image.width": width,
651
- "image.height": height,
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
- // Combine chunks
680
- const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
681
- const inputBuffer = new Uint8Array(totalLength);
682
- let offset = 0;
683
- for (const chunk of chunks) {
684
- inputBuffer.set(chunk, offset);
685
- offset += chunk.byteLength;
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
- case "blur": {
703
- pipeline = pipeline.blur(transformation.sigma);
704
- break;
705
- }
689
+ // Apply transformation (reuse buffered transform logic)
690
+ let pipeline = sharp(inputBuffer);
706
691
 
707
- case "rotate": {
708
- const options = transformation.background
709
- ? { background: transformation.background }
710
- : undefined;
711
- pipeline = pipeline.rotate(transformation.angle, options);
712
- break;
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
- case "flip": {
716
- if (transformation.direction === "horizontal") {
717
- pipeline = pipeline.flop();
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
- case "grayscale": {
725
- pipeline = pipeline.grayscale();
726
- break;
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
- case "sepia": {
730
- pipeline = pipeline.tint({ r: 112, g: 66, b: 20 });
731
- break;
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
- case "brightness": {
735
- const multiplier = 1 + transformation.value / 100;
736
- pipeline = pipeline.modulate({ brightness: multiplier });
737
- break;
738
- }
725
+ case "grayscale": {
726
+ pipeline = pipeline.grayscale();
727
+ break;
728
+ }
739
729
 
740
- case "contrast": {
741
- const a = 1 + transformation.value / 100;
742
- pipeline = pipeline.linear(a, 0);
743
- break;
744
- }
730
+ case "sepia": {
731
+ pipeline = pipeline.tint({ r: 112, g: 66, b: 20 });
732
+ break;
733
+ }
745
734
 
746
- case "sharpen": {
747
- if (transformation.sigma !== undefined) {
748
- pipeline = pipeline.sharpen({ sigma: transformation.sigma });
749
- } else {
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
- case "watermark":
756
- case "logo":
757
- case "text": {
758
- // These transformations require async operations and metadata lookups
759
- // Fall back to the buffered transform for these complex cases
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
- default: {
768
- return yield* Effect.fail(
769
- UploadistaError.fromCode("UNKNOWN_ERROR", {
770
- body: `Unsupported transformation type: ${(transformation as { type: string }).type}`,
771
- }),
772
- );
773
- }
774
- }
775
-
776
- // Process through Sharp and output as stream
777
- return Stream.async<Uint8Array, UploadistaError>((emit) => {
778
- // Use Sharp's streaming output
779
- const outputStream = new PassThrough();
780
- const outputChunks: Buffer[] = [];
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: `Sharp streaming transform failed: ${error.message}`,
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
- // Cleanup
803
- return Effect.sync(() => {
804
- outputStream.destroy();
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
- }).pipe(
808
- withOperationSpan("image", "transform-stream", {
809
- "image.transformation_type": transformation.type,
810
- }),
811
- );
812
- },
813
- }),
814
- );
808
+ }).pipe(
809
+ withOperationSpan("image", "transform-stream", {
810
+ "image.transformation_type": transformation.type,
811
+ }),
812
+ );
813
+ },
814
+ }),
815
+ );