@uploadista/flow-images-nodes 0.0.18 → 0.0.20-beta.1

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.
@@ -1,3 +1,4 @@
1
+ import { UploadistaError } from "@uploadista/core";
1
2
  import {
2
3
  applyFileNaming,
3
4
  buildNamingContext,
@@ -7,6 +8,8 @@ import {
7
8
  ImagePlugin,
8
9
  type OptimizeParams,
9
10
  STORAGE_OUTPUT_TYPE_ID,
11
+ type StreamingConfig,
12
+ type TransformMode,
10
13
  } from "@uploadista/core/flow";
11
14
  import { Effect } from "effect";
12
15
 
@@ -29,28 +32,111 @@ const formatToExtension: Record<OptimizeParams["format"], string> = {
29
32
  /**
30
33
  * Creates an optimize node that optimizes images for web delivery.
31
34
  *
35
+ * Supports both buffered and streaming modes for memory-efficient processing
36
+ * of large images. In streaming mode, the image is read and processed
37
+ * incrementally, reducing peak memory usage.
38
+ *
32
39
  * @param id - Unique node identifier
33
40
  * @param params - Optimize parameters (quality, format)
34
41
  * @param options - Optional configuration
35
42
  * @param options.keepOutput - Whether to keep output in flow results
36
43
  * @param options.naming - File naming configuration (auto suffix: `${format}`)
44
+ * @param options.mode - Transform mode: "buffered" (default), "streaming", or "auto"
45
+ * @param options.streamingConfig - Streaming configuration (file size threshold, chunk size)
37
46
  *
38
47
  * @example
39
48
  * ```typescript
40
- * // With auto-naming: "photo.jpg" -> "photo-webp.webp"
49
+ * // Buffered mode (default) - "photo.jpg" -> "photo-webp.webp"
41
50
  * const optimize = yield* createOptimizeNode("opt-1", { quality: 80, format: "webp" }, {
42
51
  * naming: { mode: "auto" }
43
52
  * });
53
+ *
54
+ * // Streaming mode for large files
55
+ * const optimizeStreaming = yield* createOptimizeNode("opt-2", { quality: 80, format: "webp" }, {
56
+ * mode: "streaming",
57
+ * naming: { mode: "auto" }
58
+ * });
59
+ *
60
+ * // Auto mode - uses streaming for files > 1MB
61
+ * const optimizeAuto = yield* createOptimizeNode("opt-3", { quality: 80, format: "webp" }, {
62
+ * mode: "auto",
63
+ * streamingConfig: { fileSizeThreshold: 1_048_576 },
64
+ * naming: { mode: "auto" }
65
+ * });
44
66
  * ```
45
67
  */
46
68
  export function createOptimizeNode(
47
69
  id: string,
48
70
  { quality, format }: OptimizeParams,
49
- options?: { keepOutput?: boolean; naming?: FileNamingConfig },
71
+ options?: {
72
+ keepOutput?: boolean;
73
+ naming?: FileNamingConfig;
74
+ mode?: TransformMode;
75
+ streamingConfig?: StreamingConfig;
76
+ },
50
77
  ) {
51
78
  return Effect.gen(function* () {
52
79
  const imageService = yield* ImagePlugin;
53
80
 
81
+ // Determine if streaming is available and requested
82
+ const supportsStreaming = imageService.supportsStreaming ?? false;
83
+ const requestedMode = options?.mode ?? "buffered";
84
+
85
+ // If streaming requested but not supported, fall back to buffered
86
+ const effectiveMode: TransformMode =
87
+ requestedMode === "buffered"
88
+ ? "buffered"
89
+ : supportsStreaming
90
+ ? requestedMode
91
+ : "buffered";
92
+
93
+ // Helper to build output metadata from optimized result
94
+ const buildOutputMetadata = (file: {
95
+ metadata?: Record<string, unknown>;
96
+ flow?: { flowId?: string; jobId?: string };
97
+ }) => {
98
+ const newType = formatToMimeType[format];
99
+ const newExtension = formatToExtension[format];
100
+
101
+ // Get original fileName
102
+ const fileName = file.metadata?.fileName;
103
+ let newFileName: string | undefined;
104
+
105
+ if (fileName && typeof fileName === "string") {
106
+ // Apply naming if configured
107
+ if (options?.naming) {
108
+ const namingConfig: FileNamingConfig = {
109
+ ...options.naming,
110
+ autoSuffix:
111
+ options.naming.autoSuffix ?? ((ctx) => ctx.format ?? format),
112
+ };
113
+ const namingContext = buildNamingContext(
114
+ file as Parameters<typeof buildNamingContext>[0],
115
+ {
116
+ flowId: file.flow?.flowId ?? "",
117
+ jobId: file.flow?.jobId ?? "",
118
+ nodeId: id,
119
+ nodeType: "optimize",
120
+ },
121
+ { format, quality },
122
+ );
123
+ // Apply naming to get base name with suffix
124
+ const namedFile = applyFileNaming(
125
+ file as Parameters<typeof applyFileNaming>[0],
126
+ namingContext,
127
+ namingConfig,
128
+ );
129
+ // Replace extension with new format extension
130
+ newFileName = `${getBaseName(namedFile)}.${newExtension}`;
131
+ } else {
132
+ // No naming config, just update extension
133
+ newFileName = fileName.replace(/\.[^.]+$/, `.${newExtension}`);
134
+ }
135
+ }
136
+
137
+ return { newType, newFileName };
138
+ };
139
+
54
140
  return yield* createTransformNode({
55
141
  id,
56
142
  name: "Optimize",
@@ -61,46 +147,14 @@ export function createOptimizeNode(
61
147
  // Note: naming is handled in transform since format changes extension
62
148
  nodeType: "optimize",
63
149
  namingVars: { format, quality },
150
+ mode: effectiveMode,
151
+ streamingConfig: options?.streamingConfig,
152
+ // Buffered transform (used when mode is "buffered" or "auto" selects buffered)
64
153
  transform: (inputBytes, file) =>
65
154
  Effect.map(
66
155
  imageService.optimize(inputBytes, { quality, format }),
67
156
  (optimizedBytes) => {
68
- // Return bytes with updated metadata if format changes
69
- const newType = formatToMimeType[format];
70
- const newExtension = formatToExtension[format];
71
-
72
- // Get original fileName
73
- const fileName = file.metadata?.fileName;
74
- let newFileName: string | undefined;
75
-
76
- if (fileName && typeof fileName === "string") {
77
- // Apply naming if configured
78
- if (options?.naming) {
79
- const namingConfig: FileNamingConfig = {
80
- ...options.naming,
81
- autoSuffix:
82
- options.naming.autoSuffix ?? ((ctx) => ctx.format ?? format),
83
- };
84
- const namingContext = buildNamingContext(
85
- file,
86
- {
87
- flowId: file.flow?.flowId ?? "",
88
- jobId: file.flow?.jobId ?? "",
89
- nodeId: id,
90
- nodeType: "optimize",
91
- },
92
- { format, quality },
93
- );
94
- // Apply naming to get base name with suffix
95
- const namedFile = applyFileNaming(file, namingContext, namingConfig);
96
- // Replace extension with new format extension
97
- newFileName = `${getBaseName(namedFile)}.${newExtension}`;
98
- } else {
99
- // No naming config, just update extension
100
- newFileName = fileName.replace(/\.[^.]+$/, `.${newExtension}`);
101
- }
102
- }
103
-
157
+ const { newType, newFileName } = buildOutputMetadata(file);
104
158
  return {
105
159
  bytes: optimizedBytes,
106
160
  type: newType,
@@ -110,6 +164,30 @@ export function createOptimizeNode(
110
164
  | { bytes: Uint8Array; type: string; fileName?: string };
111
165
  },
112
166
  ),
167
+ // Streaming transform (used when mode is "streaming" or "auto" selects streaming)
168
+ streamingTransform: imageService.optimizeStream
169
+ ? (inputStream, file) => {
170
+ const optimizeStreamFn = imageService.optimizeStream;
171
+ if (!optimizeStreamFn) {
172
+ throw UploadistaError.fromCode("UNKNOWN_ERROR");
173
+ }
174
+ return Effect.gen(function* () {
175
+ // Use the streaming optimization
176
+ const outputStream = yield* optimizeStreamFn(inputStream, {
177
+ quality,
178
+ format,
179
+ });
180
+
181
+ const { newType, newFileName } = buildOutputMetadata(file);
182
+
183
+ return {
184
+ stream: outputStream,
185
+ type: newType,
186
+ fileName: newFileName,
187
+ };
188
+ });
189
+ }
190
+ : undefined,
113
191
  });
114
192
  });
115
193
  }
@@ -4,34 +4,71 @@ import {
4
4
  ImagePlugin,
5
5
  type ResizeParams,
6
6
  STORAGE_OUTPUT_TYPE_ID,
7
+ type StreamingConfig,
8
+ type TransformMode,
7
9
  } from "@uploadista/core/flow";
8
10
  import { Effect } from "effect";
9
11
 
10
12
  /**
11
13
  * Creates a resize node that resizes images to specified dimensions.
12
14
  *
15
+ * Supports both buffered and streaming modes for memory-efficient processing
16
+ * of large images. In streaming mode, the image is read and processed
17
+ * incrementally, reducing peak memory usage.
18
+ *
13
19
  * @param id - Unique node identifier
14
20
  * @param params - Resize parameters (width, height, fit)
15
21
  * @param options - Optional configuration
16
22
  * @param options.keepOutput - Whether to keep output in flow results
17
23
  * @param options.naming - File naming configuration (auto suffix: `${width}x${height}`)
24
+ * @param options.mode - Transform mode: "buffered", "streaming", or "auto" (default)
25
+ * @param options.streamingConfig - Streaming configuration (file size threshold, chunk size)
18
26
  *
19
27
  * @example
20
28
  * ```typescript
21
- * // With auto-naming: "photo.jpg" -> "photo-800x600.jpg"
29
+ * // Auto mode (default) - uses streaming for files > 1MB, otherwise buffered
22
30
  * const resize = yield* createResizeNode("resize-1", { width: 800, height: 600 }, {
23
31
  * naming: { mode: "auto" }
24
32
  * });
33
+ *
34
+ * // Force buffered mode for small files
35
+ * const resizeBuffered = yield* createResizeNode("resize-2", { width: 800, height: 600 }, {
36
+ * mode: "buffered",
37
+ * naming: { mode: "auto" }
38
+ * });
39
+ *
40
+ * // Force streaming mode for memory efficiency
41
+ * const resizeStreaming = yield* createResizeNode("resize-3", { width: 800, height: 600 }, {
42
+ * mode: "streaming",
43
+ * naming: { mode: "auto" }
44
+ * });
25
45
  * ```
26
46
  */
27
47
  export function createResizeNode(
28
48
  id: string,
29
49
  { width, height, fit }: ResizeParams,
30
- options?: { keepOutput?: boolean; naming?: FileNamingConfig },
50
+ options?: {
51
+ keepOutput?: boolean;
52
+ naming?: FileNamingConfig;
53
+ mode?: TransformMode;
54
+ streamingConfig?: StreamingConfig;
55
+ },
31
56
  ) {
32
57
  return Effect.gen(function* () {
33
58
  const imageService = yield* ImagePlugin;
34
59
 
60
+ // Determine if streaming is available and requested
61
+ const supportsStreaming = imageService.supportsStreaming ?? false;
62
+ const requestedMode = options?.mode ?? "auto";
63
+
64
+ // If streaming requested but not supported, fall back to buffered
65
+ const effectiveMode: TransformMode =
66
+ requestedMode === "buffered"
67
+ ? "buffered"
68
+ : supportsStreaming
69
+ ? requestedMode
70
+ : "buffered";
71
+
35
72
  // Build naming config with auto suffix for resize
36
73
  const namingConfig: FileNamingConfig | undefined = options?.naming
37
74
  ? {
@@ -53,8 +90,27 @@ export function createResizeNode(
53
90
  naming: namingConfig,
54
91
  nodeType: "resize",
55
92
  namingVars: { width, height },
93
+ mode: effectiveMode,
94
+ streamingConfig: options?.streamingConfig,
95
+ // Buffered transform (used when mode is "buffered" or "auto" selects buffered)
56
96
  transform: (inputBytes) =>
57
97
  imageService.resize(inputBytes, { height, width, fit }),
98
+ // Streaming transform (used when mode is "streaming" or "auto" selects streaming)
99
+ streamingTransform: imageService.resizeStream
100
+ ? (inputStream) =>
101
+ Effect.gen(function* () {
102
+ const resizeStreamFn = imageService.resizeStream;
103
+ if (!resizeStreamFn) {
104
+ throw new Error("resizeStream not available");
105
+ }
106
+ const outputStream = yield* resizeStreamFn(inputStream, {
107
+ width,
108
+ height,
109
+ fit,
110
+ });
111
+ return { stream: outputStream };
112
+ })
113
+ : undefined,
58
114
  });
59
115
  });
60
116
  }
@@ -3,9 +3,12 @@ import {
3
3
  type FileNamingConfig,
4
4
  ImagePlugin,
5
5
  STORAGE_OUTPUT_TYPE_ID,
6
+ type StreamingConfig,
6
7
  type TransformImageParams,
8
+ type TransformMode,
7
9
  } from "@uploadista/core/flow";
8
- import { Effect } from "effect";
10
+ import type { UploadistaError } from "@uploadista/core/errors";
11
+ import { Effect, Stream } from "effect";
9
12
 
10
13
  /**
11
14
  * Apply a chain of transformations to an image by reducing over the transformations array.
@@ -26,6 +29,62 @@ function applyTransformationChain(
26
29
  );
27
30
  }
28
31
 
32
+ /**
33
+ * Apply a chain of transformations using streaming where possible.
34
+ * Falls back to buffered processing for transformations that don't support streaming.
35
+ *
36
+ * @param imageService - The image plugin service to use for transformations
37
+ * @param inputStream - The input image as a stream
38
+ * @param transformations - Array of transformations to apply in sequence
39
+ * @returns Effect that resolves to the final transformed image stream
40
+ */
41
+ function applyStreamingTransformationChain(
42
+ imageService: ReturnType<typeof ImagePlugin.of>,
43
+ inputStream: Stream.Stream<Uint8Array, UploadistaError>,
44
+ transformations: TransformImageParams["transformations"],
45
+ ): Effect.Effect<Stream.Stream<Uint8Array, UploadistaError>, UploadistaError> {
46
+ const transformStreamFn = imageService.transformStream;
47
+
48
+ if (!transformStreamFn) {
49
+ // If streaming not supported, collect to buffer and use buffered chain
50
+ return Effect.gen(function* () {
51
+ // Collect stream to buffer
52
+ const chunks: Uint8Array[] = [];
53
+ yield* Stream.runForEach(inputStream, (chunk) =>
54
+ Effect.sync(() => {
55
+ chunks.push(chunk);
56
+ }),
57
+ );
58
+ const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
59
+ const inputBuffer = new Uint8Array(totalLength);
60
+ let offset = 0;
61
+ for (const chunk of chunks) {
62
+ inputBuffer.set(chunk, offset);
63
+ offset += chunk.byteLength;
64
+ }
65
+
66
+ // Apply transformations
67
+ const result = yield* applyTransformationChain(
68
+ imageService,
69
+ inputBuffer,
70
+ transformations,
71
+ );
72
+ return Stream.make(result);
73
+ });
74
+ }
75
+
76
+ // Apply each transformation in sequence using streaming
77
+ return Effect.reduce(
78
+ transformations,
79
+ inputStream,
80
+ (currentStream, transformation) =>
81
+ Effect.flatMap(
82
+ transformStreamFn(currentStream, transformation),
83
+ (outputStream) => Effect.succeed(outputStream),
84
+ ),
85
+ );
86
+ }
87
+
29
88
  /**
30
89
  * Creates a transform image node that applies multiple transformations sequentially.
31
90
  *
@@ -33,24 +92,32 @@ function applyTransformationChain(
33
92
  * together. Each transformation is applied to the output of the previous transformation,
34
93
  * allowing for powerful image manipulation pipelines.
35
94
  *
95
+ * Supports both buffered and streaming modes for memory-efficient processing
96
+ * of large images. In streaming mode, each transformation is applied in sequence
97
+ * using streaming where supported.
98
+ *
36
99
  * Supported transformations include:
37
100
  * - Basic: resize, blur, rotate, flip
38
101
  * - Filters: grayscale, sepia, brightness, contrast
39
102
  * - Effects: sharpen
40
- * - Advanced: watermark, logo, text
103
+ * - Advanced: watermark, logo, text (streaming not supported for these)
41
104
  *
42
105
  * Note: Watermark and logo transformations require imagePath to be a valid URL.
43
106
  * Images will be fetched from the provided URL during transformation.
107
+ * Streaming mode is not supported for watermark, logo, and text transformations;
108
+ * these will cause fallback to buffered mode.
44
109
  *
45
110
  * @param id - Unique identifier for this node
46
111
  * @param params - Parameters including the transformations array
47
112
  * @param options - Optional configuration
48
113
  * @param options.keepOutput - Whether to keep output in flow results
49
114
  * @param options.naming - File naming configuration (auto suffix: `transformed`)
115
+ * @param options.mode - Transform mode: "buffered", "streaming", or "auto" (default)
116
+ * @param options.streamingConfig - Streaming configuration (file size threshold, chunk size)
50
117
  *
51
118
  * @example
52
119
  * ```typescript
53
- * // With auto-naming: "photo.jpg" -> "photo-transformed.jpg"
120
+ * // Auto mode (default) - uses streaming for files > 1MB, otherwise buffered
54
121
  * const node = yield* createTransformImageNode("transform-1", {
55
122
  * transformations: [
56
123
  * { type: 'resize', width: 800, height: 600, fit: 'cover' },
@@ -59,16 +126,57 @@ function applyTransformationChain(
59
126
  * }, {
60
127
  * naming: { mode: "auto" }
61
128
  * });
129
+ *
130
+ * // Force buffered mode for small files
131
+ * const nodeBuffered = yield* createTransformImageNode("transform-2", {
132
+ * transformations: [
133
+ * { type: 'resize', width: 800, height: 600, fit: 'cover' },
134
+ * { type: 'blur', sigma: 5 }
135
+ * ]
136
+ * }, {
137
+ * mode: "buffered",
138
+ * naming: { mode: "auto" }
139
+ * });
140
+ *
141
+ * // Force streaming mode for memory efficiency
142
+ * const nodeStreaming = yield* createTransformImageNode("transform-3", {
143
+ * transformations: [{ type: 'grayscale' }]
144
+ * }, {
145
+ * mode: "streaming",
146
+ * naming: { mode: "auto" }
147
+ * });
62
148
  * ```
63
149
  */
64
150
  export function createTransformImageNode(
65
151
  id: string,
66
152
  { transformations }: TransformImageParams,
67
- options?: { keepOutput?: boolean; naming?: FileNamingConfig },
153
+ options?: {
154
+ keepOutput?: boolean;
155
+ naming?: FileNamingConfig;
156
+ mode?: TransformMode;
157
+ streamingConfig?: StreamingConfig;
158
+ },
68
159
  ) {
69
160
  return Effect.gen(function* () {
70
161
  const imageService = yield* ImagePlugin;
71
162
 
163
+ // Determine if streaming is available and requested
164
+ const supportsStreaming = imageService.supportsStreaming ?? false;
165
+ const requestedMode = options?.mode ?? "auto";
166
+
167
+ // Check if any transformations don't support streaming
168
+ const hasUnsupportedTransformations = transformations.some(
169
+ (t) => t.type === "watermark" || t.type === "logo" || t.type === "text",
170
+ );
171
+
172
+ // If streaming requested but not supported or unsupported transformations, fall back to buffered
173
+ const effectiveMode: TransformMode =
174
+ requestedMode === "buffered"
175
+ ? "buffered"
176
+ : supportsStreaming && !hasUnsupportedTransformations
177
+ ? requestedMode
178
+ : "buffered";
179
+
72
180
  // Build naming config with auto suffix for transform-image
73
181
  const namingConfig: FileNamingConfig | undefined = options?.naming
74
182
  ? {
@@ -86,8 +194,25 @@ export function createTransformImageNode(
86
194
  keepOutput: options?.keepOutput,
87
195
  naming: namingConfig,
88
196
  nodeType: "transform-image",
197
+ mode: effectiveMode,
198
+ streamingConfig: options?.streamingConfig,
199
+ // Buffered transform (used when mode is "buffered" or "auto" selects buffered)
89
200
  transform: (inputBytes) =>
90
201
  applyTransformationChain(imageService, inputBytes, transformations),
202
+ // Streaming transform (used when mode is "streaming" or "auto" selects streaming)
203
+ streamingTransform:
204
+ supportsStreaming && !hasUnsupportedTransformations
205
+ ? (inputStream) =>
206
+ Effect.gen(function* () {
207
+ const outputStream =
208
+ yield* applyStreamingTransformationChain(
209
+ imageService,
210
+ inputStream,
211
+ transformations,
212
+ );
213
+ return { stream: outputStream };
214
+ })
215
+ : undefined,
91
216
  });
92
217
  });
93
218
  }