@uploadista/flow-images-photon 0.0.3

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.
@@ -0,0 +1,5 @@
1
+
2
+ 
3
+ > @uploadista/flow-images-photon@0.0.2 build /Users/denislaboureyras/Documents/uploadista/dev/uploadista-workspace/uploadista-sdk/packages/flow/images/photon
4
+ > tsc -b
5
+
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 uploadista
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # @uploadista/flow-images-photon
2
+
3
+ Cloudflare Photon image processing for Uploadista flows. Edge-deployed image transformations with global performance.
4
+
5
+ ## Overview
6
+
7
+ Photon provides serverless image processing at Cloudflare edge:
8
+
9
+ - **Edge Location**: Process images at 300+ edge locations
10
+ - **Global Performance**: Sub-10ms processing globally
11
+ - **No Infrastructure**: Serverless image operations
12
+ - **Multiple Formats**: JPEG, PNG, WebP, AVIF
13
+ - **Effects**: Resize, crop, blur, sharpen, filter
14
+
15
+ Perfect for Cloudflare Workers-based upload servers.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @uploadista/flow-images-photon
21
+ # or
22
+ pnpm add @uploadista/flow-images-photon
23
+ ```
24
+
25
+ ### Prerequisites
26
+
27
+ - Cloudflare Workers project
28
+ - Photon API access (included with Workers)
29
+
30
+ ## Quick Start
31
+
32
+ ```typescript
33
+ import { imagePluginServerless } from "@uploadista/flow-images-photon";
34
+
35
+ export default {
36
+ async fetch(request: Request, env: any) {
37
+ // Photon processor available globally
38
+ // Images processed at edge
39
+ },
40
+ };
41
+ ```
42
+
43
+ ## Features
44
+
45
+ - ✅ **Global Edge**: 300+ locations worldwide
46
+ - ✅ **Instant**: No cold starts, ~10ms latency
47
+ - ✅ **Serverless**: No servers to manage
48
+ - ✅ **Auto-Scaling**: Handles any traffic
49
+ - ✅ **Multiple Formats**: Automatic optimization
50
+
51
+ ## Node Types
52
+
53
+ ### Resize (Edge)
54
+
55
+ ```typescript
56
+ {
57
+ type: "resize",
58
+ params: {
59
+ width: 800,
60
+ height: 600,
61
+ fit: "cover",
62
+ },
63
+ }
64
+ ```
65
+
66
+ ### Optimize (Edge)
67
+
68
+ ```typescript
69
+ {
70
+ type: "optimize",
71
+ params: {
72
+ quality: 85,
73
+ format: "webp",
74
+ },
75
+ }
76
+ ```
77
+
78
+ ## Use Cases
79
+
80
+ - Responsive images globally
81
+ - Real-time thumbnail generation
82
+ - Format conversion at edge
83
+ - Global CDN integration
84
+ - Instant image optimization
85
+
86
+ ## Examples
87
+
88
+ ### Global Responsive Images
89
+
90
+ ```typescript
91
+ const responsiveFlow = {
92
+ nodes: [
93
+ { id: "input", type: "input" },
94
+ { id: "split", type: "multiplex", params: { outputCount: 3 } },
95
+ {
96
+ id: "mobile",
97
+ type: "resize",
98
+ params: { width: 400, height: 300, fit: "cover" },
99
+ },
100
+ {
101
+ id: "tablet",
102
+ type: "resize",
103
+ params: { width: 800, height: 600, fit: "contain" },
104
+ },
105
+ {
106
+ id: "desktop",
107
+ type: "resize",
108
+ params: { width: 1200, height: 900, fit: "cover" },
109
+ },
110
+ { id: "r2", type: "r2-store" },
111
+ { id: "output", type: "output" },
112
+ ],
113
+ };
114
+ ```
115
+
116
+ ## Performance
117
+
118
+ | Operation | Latency |
119
+ |-----------|---------|
120
+ | Resize to 800x600 | ~5-10ms |
121
+ | Optimize (WebP) | ~10-15ms |
122
+ | Global delivery | ~50-100ms (edge+network) |
123
+
124
+ Processing at edge location closest to user.
125
+
126
+ ## Limitations
127
+
128
+ - **Photon Features**: Subset of Sharp (most common ops supported)
129
+ - **Size Limits**: 50MB recommended max
130
+ - **API Rate**: Subject to Cloudflare quotas
131
+
132
+ ## Related Packages
133
+
134
+ - [@uploadista/flow-images-nodes](../nodes) - Base types
135
+ - [@uploadista/flow-images-sharp](../sharp) - Node.js alternative
136
+ - [@uploadista/adapters-hono](../../servers/adapters-hono) - Hono integration
137
+ - [@uploadista/server](../../servers/server) - Upload server
138
+
139
+ ## License
140
+
141
+ See [LICENSE](../../../LICENSE) in the main repository.
142
+
143
+ ## See Also
144
+
145
+ - [Cloudflare Photon](https://developers.cloudflare.com/workers/platform/pricing/plans/) - Official Photon
146
+ - [FLOW_NODES.md](../FLOW_NODES.md) - All available nodes
147
+ - [Sharp Node](../sharp/README.md) - Node.js alternative
@@ -0,0 +1,6 @@
1
+ export declare const round2dp: (num: number) => number;
2
+ export declare const calculateImageSize: (objectFit: "contain" | "cover" | "fill" | "none" | "scale-down", currentWidth: number, currentHeight: number, containerWidth: number, containerHeight: number) => {
3
+ width: number;
4
+ height: number;
5
+ };
6
+ //# sourceMappingURL=common.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,QAAQ,GAAI,KAAK,MAAM,WACY,CAAC;AAEjD,eAAO,MAAM,kBAAkB,GAC7B,WAAW,SAAS,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,YAAY,EAC/D,cAAc,MAAM,EACpB,eAAe,MAAM,EACrB,gBAAgB,MAAM,EACtB,iBAAiB,MAAM;;;CAsFxB,CAAC"}
package/dist/common.js ADDED
@@ -0,0 +1,74 @@
1
+ export const round2dp = (num) => Math.round((num + Number.EPSILON) * 100) / 100;
2
+ export const calculateImageSize = (objectFit, currentWidth, currentHeight, containerWidth, containerHeight) => {
3
+ let newSize = {};
4
+ switch (objectFit) {
5
+ case "contain": {
6
+ // Calculate dimensions while maintaining aspect ratio
7
+ const widthRatioContain = containerWidth / currentWidth;
8
+ const heightRatioContain = containerHeight / currentHeight;
9
+ const scaleContain = Math.min(widthRatioContain, heightRatioContain);
10
+ newSize = {
11
+ width: currentWidth * scaleContain,
12
+ height: currentHeight * scaleContain,
13
+ };
14
+ break;
15
+ }
16
+ case "cover": {
17
+ // Calculate dimensions while maintaining aspect ratio
18
+ const widthRatioCover = containerWidth / currentWidth;
19
+ const heightRatioCover = containerHeight / currentHeight;
20
+ const scaleCover = Math.max(widthRatioCover, heightRatioCover);
21
+ const newWidthCover = currentWidth * scaleCover;
22
+ const newHeightCover = currentHeight * scaleCover;
23
+ // Adjust dimensions if it doesn't cover the container completely
24
+ if (newWidthCover < containerWidth || newHeightCover < containerHeight) {
25
+ const scaleCoverAdjusted = Math.max(containerWidth / currentWidth, containerHeight / currentHeight);
26
+ newSize = {
27
+ width: currentWidth * scaleCoverAdjusted,
28
+ height: currentHeight * scaleCoverAdjusted,
29
+ };
30
+ break;
31
+ }
32
+ newSize = {
33
+ width: newWidthCover,
34
+ height: newHeightCover,
35
+ };
36
+ break;
37
+ }
38
+ case "fill":
39
+ // Stretch image to fill container
40
+ newSize = {
41
+ width: containerWidth,
42
+ height: containerHeight,
43
+ };
44
+ break;
45
+ case "none":
46
+ // Keep original image size
47
+ newSize = {
48
+ width: currentWidth,
49
+ height: currentHeight,
50
+ };
51
+ break;
52
+ case "scale-down": {
53
+ // Calculate dimensions based on contain and none values
54
+ const widthRatioScaleDown = containerWidth / currentWidth;
55
+ const heightRatioScaleDown = containerHeight / currentHeight;
56
+ const scaleScaleDown = Math.min(1, Math.min(widthRatioScaleDown, heightRatioScaleDown));
57
+ newSize = {
58
+ width: currentWidth * scaleScaleDown,
59
+ height: currentHeight * scaleScaleDown,
60
+ };
61
+ break;
62
+ }
63
+ default:
64
+ throw new Error("Invalid object fit value");
65
+ }
66
+ if (!newSize?.width || !newSize?.height) {
67
+ throw new Error("Invalid dimensions");
68
+ }
69
+ return {
70
+ width: round2dp(newSize.width),
71
+ height: round2dp(newSize.height),
72
+ };
73
+ // return newSize;
74
+ };
@@ -0,0 +1,14 @@
1
+ import { PhotonImage, Rgba } from "@cf-wasm/photon/node";
2
+ import { ImagePlugin } from "@uploadista/core/flow";
3
+ import { Layer } from "effect";
4
+ import type tinycolor from "tinycolor2";
5
+ export declare const tinyColorToPhotonRGBA: (color: tinycolor.Instance) => Rgba;
6
+ export declare const autoResize: (image: PhotonImage, newWidth?: number, newHeight?: number, options?: {
7
+ fit?: "contain" | "cover" | "fill";
8
+ fit_cover_letterbox_color?: tinycolor.Instance;
9
+ }) => PhotonImage;
10
+ export declare const imageToFormat: (image: PhotonImage, format: "webp" | "jpeg" | "png", options?: {
11
+ jpeg_quality?: number;
12
+ }) => Uint8Array<ArrayBufferLike>;
13
+ export declare const imagePluginNode: Layer.Layer<ImagePlugin, never, never>;
14
+ //# sourceMappingURL=image-plugin-node.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-plugin-node.d.ts","sourceRoot":"","sources":["../src/image-plugin-node.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,WAAW,EAKX,IAAI,EAEL,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACpD,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,SAAS,MAAM,YAAY,CAAC;AAGxC,eAAO,MAAM,qBAAqB,GAAI,OAAO,SAAS,CAAC,QAAQ,SAG9D,CAAC;AAEF,eAAO,MAAM,UAAU,GACrB,OAAO,WAAW,EAClB,WAAW,MAAM,EACjB,YAAY,MAAM,EAClB,UAAU;IACR,GAAG,CAAC,EAAE,SAAS,GAAG,OAAO,GAAG,MAAM,CAAC;IACnC,yBAAyB,CAAC,EAAE,SAAS,CAAC,QAAQ,CAAC;CAChD,gBA4EF,CAAC;AAEF,eAAO,MAAM,aAAa,GACxB,OAAO,WAAW,EAClB,QAAQ,MAAM,GAAG,MAAM,GAAG,KAAK,EAC/B,UAAU;IACR,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,gCAmBF,CAAC;AAEF,eAAO,MAAM,eAAe,wCA4C3B,CAAC"}
@@ -0,0 +1,97 @@
1
+ import { crop, PhotonImage, padding_bottom, padding_left, padding_right, padding_top, Rgba, resize, } from "@cf-wasm/photon/node";
2
+ import { ImagePlugin } from "@uploadista/core/flow";
3
+ import { Effect, Layer } from "effect";
4
+ import { calculateImageSize } from "./common";
5
+ export const tinyColorToPhotonRGBA = (color) => {
6
+ const rgba = color.toRgb();
7
+ return new Rgba(rgba.r, rgba.g, rgba.b, rgba.a);
8
+ };
9
+ export const autoResize = (image, newWidth, newHeight, options) => {
10
+ const currentWidth = image.get_width();
11
+ const currentHeight = image.get_height();
12
+ if (!newWidth && !newHeight) {
13
+ throw new Error("At least one of width or height is required");
14
+ }
15
+ if (newWidth && !newHeight) {
16
+ newHeight = Math.floor((newWidth / currentWidth) * currentHeight);
17
+ }
18
+ else if (newHeight && !newWidth) {
19
+ newWidth = Math.floor((newHeight / currentHeight) * currentWidth);
20
+ }
21
+ if (!newWidth || !newHeight) {
22
+ throw new Error("Invalid width or height");
23
+ }
24
+ if (newWidth === currentWidth && newHeight === currentHeight) {
25
+ return image;
26
+ }
27
+ const fit = options?.fit || "cover";
28
+ const dem = calculateImageSize(fit, currentWidth, currentHeight, newWidth, newHeight);
29
+ image = resize(image, dem.width, dem.height, 1);
30
+ const [updatedWidth, updatedHeight] = [image.get_width(), image.get_height()];
31
+ if (fit === "contain" && options?.fit_cover_letterbox_color) {
32
+ const paddingX = Math.floor((newWidth - updatedWidth) / 2);
33
+ const paddingY = Math.floor((newHeight - updatedHeight) / 2);
34
+ if (paddingY > 0) {
35
+ image = padding_top(image, paddingY, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
36
+ image = padding_bottom(image, paddingY, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
37
+ }
38
+ if (paddingX > 0) {
39
+ image = padding_left(image, paddingX, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
40
+ image = padding_right(image, paddingX, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
41
+ }
42
+ }
43
+ else if (fit === "cover") {
44
+ //crop to center
45
+ const cropX = Math.floor(updatedWidth - newWidth) / 2;
46
+ const cropY = Math.floor(updatedHeight - newHeight) / 2;
47
+ //top left to down right
48
+ image = crop(image, cropX, cropY, newWidth + cropX, newHeight + cropY);
49
+ }
50
+ return image;
51
+ };
52
+ export const imageToFormat = (image, format, options) => {
53
+ let outputBytes;
54
+ switch (format) {
55
+ case "webp":
56
+ outputBytes = image.get_bytes_webp();
57
+ break;
58
+ case "jpeg":
59
+ outputBytes = image.get_bytes_jpeg(options?.jpeg_quality || 100);
60
+ break;
61
+ case "png":
62
+ outputBytes = image.get_bytes();
63
+ break;
64
+ default:
65
+ outputBytes = image.get_bytes_jpeg(options?.jpeg_quality || 100);
66
+ }
67
+ return outputBytes;
68
+ };
69
+ export const imagePluginNode = Layer.succeed(ImagePlugin, ImagePlugin.of({
70
+ optimize: (inputBytes, { quality }) => {
71
+ // create a PhotonImage instance
72
+ const inputImage = PhotonImage.new_from_byteslice(inputBytes);
73
+ // get jpeg bytes
74
+ const outputBytes = inputImage.get_bytes_jpeg(quality);
75
+ // call free() method to free memory
76
+ inputImage.free();
77
+ return Effect.succeed(outputBytes);
78
+ },
79
+ resize: (inputBytes, { width, height, fit }) => {
80
+ if (!width && !height) {
81
+ throw new Error("Either width or height must be specified for resize");
82
+ }
83
+ // create a PhotonImage instance
84
+ const inputImage = PhotonImage.new_from_byteslice(inputBytes);
85
+ // resize image using photon
86
+ const outputImage = autoResize(inputImage, width ?? inputImage.get_width(), height ?? inputImage.get_height(), { fit });
87
+ // get webp bytes
88
+ const outputBytes = outputImage.get_bytes_webp();
89
+ // for other formats
90
+ // png : outputImage.get_bytes();
91
+ // jpeg : outputImage.get_bytes_jpeg(quality);
92
+ // call free() method to free memory
93
+ inputImage.free();
94
+ outputImage.free();
95
+ return Effect.succeed(outputBytes);
96
+ },
97
+ }));
@@ -0,0 +1,4 @@
1
+ import { ImagePlugin } from "@uploadista/core/flow";
2
+ import { Layer } from "effect";
3
+ export declare const imagePluginServerless: Layer.Layer<ImagePlugin, never, never>;
4
+ //# sourceMappingURL=image-plugin-serverless.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-plugin-serverless.d.ts","sourceRoot":"","sources":["../src/image-plugin-serverless.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACpD,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AA8FvC,eAAO,MAAM,qBAAqB,wCA4CjC,CAAC"}
@@ -0,0 +1,80 @@
1
+ import { crop, PhotonImage, padding_bottom, padding_left, padding_right, padding_top, Rgba, resize, } from "@cf-wasm/photon";
2
+ import { ImagePlugin } from "@uploadista/core/flow";
3
+ import { Effect, Layer } from "effect";
4
+ import { calculateImageSize } from "./common";
5
+ const tinyColorToPhotonRGBA = (color) => {
6
+ const rgba = color.toRgb();
7
+ return new Rgba(rgba.r, rgba.g, rgba.b, rgba.a);
8
+ };
9
+ const autoResize = (image, newWidth, newHeight, options) => {
10
+ const currentWidth = image.get_width();
11
+ const currentHeight = image.get_height();
12
+ if (!newWidth && !newHeight) {
13
+ throw new Error("At least one of width or height is required");
14
+ }
15
+ if (newWidth && !newHeight) {
16
+ newHeight = Math.floor((newWidth / currentWidth) * currentHeight);
17
+ }
18
+ else if (newHeight && !newWidth) {
19
+ newWidth = Math.floor((newHeight / currentHeight) * currentWidth);
20
+ }
21
+ if (!newWidth || !newHeight) {
22
+ throw new Error("Invalid width or height");
23
+ }
24
+ if (newWidth === currentWidth && newHeight === currentHeight) {
25
+ return image;
26
+ }
27
+ const fit = options?.fit || "cover";
28
+ const dem = calculateImageSize(fit, currentWidth, currentHeight, newWidth, newHeight);
29
+ image = resize(image, dem.width, dem.height, 1);
30
+ const [updatedWidth, updatedHeight] = [image.get_width(), image.get_height()];
31
+ if (fit === "contain" && options?.fit_cover_letterbox_color) {
32
+ const paddingX = Math.floor((newWidth - updatedWidth) / 2);
33
+ const paddingY = Math.floor((newHeight - updatedHeight) / 2);
34
+ if (paddingY > 0) {
35
+ image = padding_top(image, paddingY, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
36
+ image = padding_bottom(image, paddingY, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
37
+ }
38
+ if (paddingX > 0) {
39
+ image = padding_left(image, paddingX, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
40
+ image = padding_right(image, paddingX, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
41
+ }
42
+ }
43
+ else if (fit === "cover") {
44
+ //crop to center
45
+ const cropX = Math.floor(updatedWidth - newWidth) / 2;
46
+ const cropY = Math.floor(updatedHeight - newHeight) / 2;
47
+ //top left to down right
48
+ image = crop(image, cropX, cropY, newWidth + cropX, newHeight + cropY);
49
+ }
50
+ return image;
51
+ };
52
+ export const imagePluginServerless = Layer.succeed(ImagePlugin, ImagePlugin.of({
53
+ optimize: (inputBytes, { quality }) => {
54
+ // create a PhotonImage instance
55
+ const inputImage = PhotonImage.new_from_byteslice(inputBytes);
56
+ // get jpeg bytes
57
+ const outputBytes = inputImage.get_bytes_jpeg(quality);
58
+ // call free() method to free memory
59
+ inputImage.free();
60
+ return Effect.succeed(outputBytes);
61
+ },
62
+ resize: (inputBytes, { width, height, fit }) => {
63
+ if (!width && !height) {
64
+ throw new Error("Either width or height must be specified for resize");
65
+ }
66
+ // create a PhotonImage instance
67
+ const inputImage = PhotonImage.new_from_byteslice(inputBytes);
68
+ // resize image using photon
69
+ const outputImage = autoResize(inputImage, width ?? inputImage.get_width(), height ?? inputImage.get_height(), { fit });
70
+ // get webp bytes
71
+ const outputBytes = outputImage.get_bytes_webp();
72
+ // for other formats
73
+ // png : outputImage.get_bytes();
74
+ // jpeg : outputImage.get_bytes_jpeg(quality);
75
+ // call free() method to free memory
76
+ inputImage.free();
77
+ outputImage.free();
78
+ return Effect.succeed(outputBytes);
79
+ },
80
+ }));
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=image-service-common.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-service-common.d.ts","sourceRoot":"","sources":["../../src/services/image-service-common.ts"],"names":[],"mappings":""}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import { PhotonImage, Rgba } from "@cf-wasm/photon/node";
2
+ import { ImagePlugin } from "@uploadista/core/flow";
3
+ import { Layer } from "effect";
4
+ import type tinycolor from "tinycolor2";
5
+ export declare const calculateImageSize: (objectFit: "contain" | "cover" | "fill" | "none" | "scale-down", currentWidth: number, currentHeight: number, containerWidth: number, containerHeight: number) => {
6
+ width: number;
7
+ height: number;
8
+ };
9
+ export declare const tinyColorToPhotonRGBA: (color: tinycolor.Instance) => Rgba;
10
+ export declare const autoResize: (image: PhotonImage, newWidth?: number, newHeight?: number, options?: {
11
+ fit?: "contain" | "cover" | "fill";
12
+ fit_cover_letterbox_color?: tinycolor.Instance;
13
+ }) => PhotonImage;
14
+ export declare const imageToFormat: (image: PhotonImage, format: "webp" | "jpeg" | "png", options?: {
15
+ jpeg_quality?: number;
16
+ }) => Uint8Array<ArrayBufferLike>;
17
+ export declare const imageNode: Layer.Layer<ImagePlugin, never, never>;
18
+ //# sourceMappingURL=image-service-node.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-service-node.d.ts","sourceRoot":"","sources":["../../src/services/image-service-node.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,WAAW,EAKX,IAAI,EAEL,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACpD,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,SAAS,MAAM,YAAY,CAAC;AAKxC,eAAO,MAAM,kBAAkB,GAC7B,WAAW,SAAS,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,YAAY,EAC/D,cAAc,MAAM,EACpB,eAAe,MAAM,EACrB,gBAAgB,MAAM,EACtB,iBAAiB,MAAM;;;CAsFxB,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAAI,OAAO,SAAS,CAAC,QAAQ,SAG9D,CAAC;AAEF,eAAO,MAAM,UAAU,GACrB,OAAO,WAAW,EAClB,WAAW,MAAM,EACjB,YAAY,MAAM,EAClB,UAAU;IACR,GAAG,CAAC,EAAE,SAAS,GAAG,OAAO,GAAG,MAAM,CAAC;IACnC,yBAAyB,CAAC,EAAE,SAAS,CAAC,QAAQ,CAAC;CAChD,gBA4EF,CAAC;AAEF,eAAO,MAAM,aAAa,GACxB,OAAO,WAAW,EAClB,QAAQ,MAAM,GAAG,MAAM,GAAG,KAAK,EAC/B,UAAU;IACR,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,gCAmBF,CAAC;AAEF,eAAO,MAAM,SAAS,wCA4CrB,CAAC"}
@@ -0,0 +1,170 @@
1
+ import { crop, PhotonImage, padding_bottom, padding_left, padding_right, padding_top, Rgba, resize, } from "@cf-wasm/photon/node";
2
+ import { ImagePlugin } from "@uploadista/core/flow";
3
+ import { Effect, Layer } from "effect";
4
+ const round2dp = (num) => Math.round((num + Number.EPSILON) * 100) / 100;
5
+ export const calculateImageSize = (objectFit, currentWidth, currentHeight, containerWidth, containerHeight) => {
6
+ let newSize = {};
7
+ switch (objectFit) {
8
+ case "contain": {
9
+ // Calculate dimensions while maintaining aspect ratio
10
+ const widthRatioContain = containerWidth / currentWidth;
11
+ const heightRatioContain = containerHeight / currentHeight;
12
+ const scaleContain = Math.min(widthRatioContain, heightRatioContain);
13
+ newSize = {
14
+ width: currentWidth * scaleContain,
15
+ height: currentHeight * scaleContain,
16
+ };
17
+ break;
18
+ }
19
+ case "cover": {
20
+ // Calculate dimensions while maintaining aspect ratio
21
+ const widthRatioCover = containerWidth / currentWidth;
22
+ const heightRatioCover = containerHeight / currentHeight;
23
+ const scaleCover = Math.max(widthRatioCover, heightRatioCover);
24
+ const newWidthCover = currentWidth * scaleCover;
25
+ const newHeightCover = currentHeight * scaleCover;
26
+ // Adjust dimensions if it doesn't cover the container completely
27
+ if (newWidthCover < containerWidth || newHeightCover < containerHeight) {
28
+ const scaleCoverAdjusted = Math.max(containerWidth / currentWidth, containerHeight / currentHeight);
29
+ newSize = {
30
+ width: currentWidth * scaleCoverAdjusted,
31
+ height: currentHeight * scaleCoverAdjusted,
32
+ };
33
+ break;
34
+ }
35
+ newSize = {
36
+ width: newWidthCover,
37
+ height: newHeightCover,
38
+ };
39
+ break;
40
+ }
41
+ case "fill":
42
+ // Stretch image to fill container
43
+ newSize = {
44
+ width: containerWidth,
45
+ height: containerHeight,
46
+ };
47
+ break;
48
+ case "none":
49
+ // Keep original image size
50
+ newSize = {
51
+ width: currentWidth,
52
+ height: currentHeight,
53
+ };
54
+ break;
55
+ case "scale-down": {
56
+ // Calculate dimensions based on contain and none values
57
+ const widthRatioScaleDown = containerWidth / currentWidth;
58
+ const heightRatioScaleDown = containerHeight / currentHeight;
59
+ const scaleScaleDown = Math.min(1, Math.min(widthRatioScaleDown, heightRatioScaleDown));
60
+ newSize = {
61
+ width: currentWidth * scaleScaleDown,
62
+ height: currentHeight * scaleScaleDown,
63
+ };
64
+ break;
65
+ }
66
+ default:
67
+ throw new Error("Invalid object fit value");
68
+ }
69
+ if (!newSize?.width || !newSize?.height) {
70
+ throw new Error("Invalid dimensions");
71
+ }
72
+ return {
73
+ width: round2dp(newSize.width),
74
+ height: round2dp(newSize.height),
75
+ };
76
+ // return newSize;
77
+ };
78
+ export const tinyColorToPhotonRGBA = (color) => {
79
+ const rgba = color.toRgb();
80
+ return new Rgba(rgba.r, rgba.g, rgba.b, rgba.a);
81
+ };
82
+ export const autoResize = (image, newWidth, newHeight, options) => {
83
+ const currentWidth = image.get_width();
84
+ const currentHeight = image.get_height();
85
+ if (!newWidth && !newHeight) {
86
+ throw new Error("At least one of width or height is required");
87
+ }
88
+ if (newWidth && !newHeight) {
89
+ newHeight = Math.floor((newWidth / currentWidth) * currentHeight);
90
+ }
91
+ else if (newHeight && !newWidth) {
92
+ newWidth = Math.floor((newHeight / currentHeight) * currentWidth);
93
+ }
94
+ if (!newWidth || !newHeight) {
95
+ throw new Error("Invalid width or height");
96
+ }
97
+ if (newWidth === currentWidth && newHeight === currentHeight) {
98
+ return image;
99
+ }
100
+ const fit = options?.fit || "cover";
101
+ const dem = calculateImageSize(fit, currentWidth, currentHeight, newWidth, newHeight);
102
+ image = resize(image, dem.width, dem.height, 1);
103
+ const [updatedWidth, updatedHeight] = [image.get_width(), image.get_height()];
104
+ if (fit === "contain" && options?.fit_cover_letterbox_color) {
105
+ const paddingX = Math.floor((newWidth - updatedWidth) / 2);
106
+ const paddingY = Math.floor((newHeight - updatedHeight) / 2);
107
+ if (paddingY > 0) {
108
+ image = padding_top(image, paddingY, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
109
+ image = padding_bottom(image, paddingY, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
110
+ }
111
+ if (paddingX > 0) {
112
+ image = padding_left(image, paddingX, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
113
+ image = padding_right(image, paddingX, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
114
+ }
115
+ }
116
+ else if (fit === "cover") {
117
+ //crop to center
118
+ const cropX = Math.floor(updatedWidth - newWidth) / 2;
119
+ const cropY = Math.floor(updatedHeight - newHeight) / 2;
120
+ //top left to down right
121
+ image = crop(image, cropX, cropY, newWidth + cropX, newHeight + cropY);
122
+ }
123
+ return image;
124
+ };
125
+ export const imageToFormat = (image, format, options) => {
126
+ let outputBytes;
127
+ switch (format) {
128
+ case "webp":
129
+ outputBytes = image.get_bytes_webp();
130
+ break;
131
+ case "jpeg":
132
+ outputBytes = image.get_bytes_jpeg(options?.jpeg_quality || 100);
133
+ break;
134
+ case "png":
135
+ outputBytes = image.get_bytes();
136
+ break;
137
+ default:
138
+ outputBytes = image.get_bytes_jpeg(options?.jpeg_quality || 100);
139
+ }
140
+ return outputBytes;
141
+ };
142
+ export const imageNode = Layer.succeed(ImagePlugin, ImagePlugin.of({
143
+ optimize: (inputBytes, { quality }) => {
144
+ // create a PhotonImage instance
145
+ const inputImage = PhotonImage.new_from_byteslice(inputBytes);
146
+ // get jpeg bytes
147
+ const outputBytes = inputImage.get_bytes_jpeg(quality);
148
+ // call free() method to free memory
149
+ inputImage.free();
150
+ return Effect.succeed(outputBytes);
151
+ },
152
+ resize: (inputBytes, { width, height, fit }) => {
153
+ if (!width && !height) {
154
+ throw new Error("Either width or height must be specified for resize");
155
+ }
156
+ // create a PhotonImage instance
157
+ const inputImage = PhotonImage.new_from_byteslice(inputBytes);
158
+ // resize image using photon
159
+ const outputImage = autoResize(inputImage, width ?? inputImage.get_width(), height ?? inputImage.get_height(), { fit });
160
+ // get webp bytes
161
+ const outputBytes = outputImage.get_bytes_webp();
162
+ // for other formats
163
+ // png : outputImage.get_bytes();
164
+ // jpeg : outputImage.get_bytes_jpeg(quality);
165
+ // call free() method to free memory
166
+ inputImage.free();
167
+ outputImage.free();
168
+ return Effect.succeed(outputBytes);
169
+ },
170
+ }));
@@ -0,0 +1,4 @@
1
+ import { ImagePlugin } from "@uploadista/core/flow";
2
+ import { Layer } from "effect";
3
+ export declare const imageServerless: Layer.Layer<ImagePlugin, never, never>;
4
+ //# sourceMappingURL=image-service-serverless.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-service-serverless.d.ts","sourceRoot":"","sources":["../../src/services/image-service-serverless.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACpD,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AA6LvC,eAAO,MAAM,eAAe,wCA4C3B,CAAC"}
@@ -0,0 +1,153 @@
1
+ import { crop, PhotonImage, padding_bottom, padding_left, padding_right, padding_top, Rgba, resize, } from "@cf-wasm/photon";
2
+ import { ImagePlugin } from "@uploadista/core/flow";
3
+ import { Effect, Layer } from "effect";
4
+ const round2dp = (num) => Math.round((num + Number.EPSILON) * 100) / 100;
5
+ const calculateImageSize = (objectFit, currentWidth, currentHeight, containerWidth, containerHeight) => {
6
+ let newSize = {};
7
+ switch (objectFit) {
8
+ case "contain": {
9
+ // Calculate dimensions while maintaining aspect ratio
10
+ const widthRatioContain = containerWidth / currentWidth;
11
+ const heightRatioContain = containerHeight / currentHeight;
12
+ const scaleContain = Math.min(widthRatioContain, heightRatioContain);
13
+ newSize = {
14
+ width: currentWidth * scaleContain,
15
+ height: currentHeight * scaleContain,
16
+ };
17
+ break;
18
+ }
19
+ case "cover": {
20
+ // Calculate dimensions while maintaining aspect ratio
21
+ const widthRatioCover = containerWidth / currentWidth;
22
+ const heightRatioCover = containerHeight / currentHeight;
23
+ const scaleCover = Math.max(widthRatioCover, heightRatioCover);
24
+ const newWidthCover = currentWidth * scaleCover;
25
+ const newHeightCover = currentHeight * scaleCover;
26
+ // Adjust dimensions if it doesn't cover the container completely
27
+ if (newWidthCover < containerWidth || newHeightCover < containerHeight) {
28
+ const scaleCoverAdjusted = Math.max(containerWidth / currentWidth, containerHeight / currentHeight);
29
+ newSize = {
30
+ width: currentWidth * scaleCoverAdjusted,
31
+ height: currentHeight * scaleCoverAdjusted,
32
+ };
33
+ break;
34
+ }
35
+ newSize = {
36
+ width: newWidthCover,
37
+ height: newHeightCover,
38
+ };
39
+ break;
40
+ }
41
+ case "fill":
42
+ // Stretch image to fill container
43
+ newSize = {
44
+ width: containerWidth,
45
+ height: containerHeight,
46
+ };
47
+ break;
48
+ case "none":
49
+ // Keep original image size
50
+ newSize = {
51
+ width: currentWidth,
52
+ height: currentHeight,
53
+ };
54
+ break;
55
+ case "scale-down": {
56
+ // Calculate dimensions based on contain and none values
57
+ const widthRatioScaleDown = containerWidth / currentWidth;
58
+ const heightRatioScaleDown = containerHeight / currentHeight;
59
+ const scaleScaleDown = Math.min(1, Math.min(widthRatioScaleDown, heightRatioScaleDown));
60
+ newSize = {
61
+ width: currentWidth * scaleScaleDown,
62
+ height: currentHeight * scaleScaleDown,
63
+ };
64
+ break;
65
+ }
66
+ default:
67
+ throw new Error("Invalid object fit value");
68
+ }
69
+ if (!newSize?.width || !newSize?.height) {
70
+ throw new Error("Invalid dimensions");
71
+ }
72
+ return {
73
+ width: round2dp(newSize.width),
74
+ height: round2dp(newSize.height),
75
+ };
76
+ // return newSize;
77
+ };
78
+ const tinyColorToPhotonRGBA = (color) => {
79
+ const rgba = color.toRgb();
80
+ return new Rgba(rgba.r, rgba.g, rgba.b, rgba.a);
81
+ };
82
+ const autoResize = (image, newWidth, newHeight, options) => {
83
+ const currentWidth = image.get_width();
84
+ const currentHeight = image.get_height();
85
+ if (!newWidth && !newHeight) {
86
+ throw new Error("At least one of width or height is required");
87
+ }
88
+ if (newWidth && !newHeight) {
89
+ newHeight = Math.floor((newWidth / currentWidth) * currentHeight);
90
+ }
91
+ else if (newHeight && !newWidth) {
92
+ newWidth = Math.floor((newHeight / currentHeight) * currentWidth);
93
+ }
94
+ if (!newWidth || !newHeight) {
95
+ throw new Error("Invalid width or height");
96
+ }
97
+ if (newWidth === currentWidth && newHeight === currentHeight) {
98
+ return image;
99
+ }
100
+ const fit = options?.fit || "cover";
101
+ const dem = calculateImageSize(fit, currentWidth, currentHeight, newWidth, newHeight);
102
+ image = resize(image, dem.width, dem.height, 1);
103
+ const [updatedWidth, updatedHeight] = [image.get_width(), image.get_height()];
104
+ if (fit === "contain" && options?.fit_cover_letterbox_color) {
105
+ const paddingX = Math.floor((newWidth - updatedWidth) / 2);
106
+ const paddingY = Math.floor((newHeight - updatedHeight) / 2);
107
+ if (paddingY > 0) {
108
+ image = padding_top(image, paddingY, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
109
+ image = padding_bottom(image, paddingY, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
110
+ }
111
+ if (paddingX > 0) {
112
+ image = padding_left(image, paddingX, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
113
+ image = padding_right(image, paddingX, tinyColorToPhotonRGBA(options.fit_cover_letterbox_color));
114
+ }
115
+ }
116
+ else if (fit === "cover") {
117
+ //crop to center
118
+ const cropX = Math.floor(updatedWidth - newWidth) / 2;
119
+ const cropY = Math.floor(updatedHeight - newHeight) / 2;
120
+ //top left to down right
121
+ image = crop(image, cropX, cropY, newWidth + cropX, newHeight + cropY);
122
+ }
123
+ return image;
124
+ };
125
+ export const imageServerless = Layer.succeed(ImagePlugin, ImagePlugin.of({
126
+ optimize: (inputBytes, { quality }) => {
127
+ // create a PhotonImage instance
128
+ const inputImage = PhotonImage.new_from_byteslice(inputBytes);
129
+ // get jpeg bytes
130
+ const outputBytes = inputImage.get_bytes_jpeg(quality);
131
+ // call free() method to free memory
132
+ inputImage.free();
133
+ return Effect.succeed(outputBytes);
134
+ },
135
+ resize: (inputBytes, { width, height, fit }) => {
136
+ if (!width && !height) {
137
+ throw new Error("Either width or height must be specified for resize");
138
+ }
139
+ // create a PhotonImage instance
140
+ const inputImage = PhotonImage.new_from_byteslice(inputBytes);
141
+ // resize image using photon
142
+ const outputImage = autoResize(inputImage, width ?? inputImage.get_width(), height ?? inputImage.get_height(), { fit });
143
+ // get webp bytes
144
+ const outputBytes = outputImage.get_bytes_webp();
145
+ // for other formats
146
+ // png : outputImage.get_bytes();
147
+ // jpeg : outputImage.get_bytes_jpeg(quality);
148
+ // call free() method to free memory
149
+ inputImage.free();
150
+ outputImage.free();
151
+ return Effect.succeed(outputBytes);
152
+ },
153
+ }));
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@uploadista/flow-images-photon",
3
+ "type": "module",
4
+ "version": "0.0.3",
5
+ "description": "Photon image processing service for Uploadista Flow",
6
+ "license": "MIT",
7
+ "author": "Uploadista",
8
+ "exports": {
9
+ "./*": {
10
+ "types": "./dist/*.d.ts",
11
+ "import": "./dist/*.js",
12
+ "default": "./dist/*.js"
13
+ }
14
+ },
15
+ "dependencies": {
16
+ "@cf-wasm/photon": "0.1.31",
17
+ "effect": "3.18.4",
18
+ "tinycolor2": "1.6.0",
19
+ "zod": "4.1.12",
20
+ "@uploadista/core": "0.0.3"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "24.8.1",
24
+ "@types/tinycolor2": "1.4.6",
25
+ "@uploadista/typescript-config": "0.0.3"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc -b",
29
+ "format": "biome format --write ./src",
30
+ "lint": "biome lint --write ./src",
31
+ "check": "biome check --write ./src"
32
+ }
33
+ }
package/src/common.ts ADDED
@@ -0,0 +1,95 @@
1
+ export const round2dp = (num: number) =>
2
+ Math.round((num + Number.EPSILON) * 100) / 100;
3
+
4
+ export const calculateImageSize = (
5
+ objectFit: "contain" | "cover" | "fill" | "none" | "scale-down",
6
+ currentWidth: number,
7
+ currentHeight: number,
8
+ containerWidth: number,
9
+ containerHeight: number,
10
+ ) => {
11
+ let newSize: {
12
+ width?: number;
13
+ height?: number;
14
+ } = {};
15
+
16
+ switch (objectFit) {
17
+ case "contain": {
18
+ // Calculate dimensions while maintaining aspect ratio
19
+ const widthRatioContain = containerWidth / currentWidth;
20
+ const heightRatioContain = containerHeight / currentHeight;
21
+ const scaleContain = Math.min(widthRatioContain, heightRatioContain);
22
+ newSize = {
23
+ width: currentWidth * scaleContain,
24
+ height: currentHeight * scaleContain,
25
+ };
26
+ break;
27
+ }
28
+ case "cover": {
29
+ // Calculate dimensions while maintaining aspect ratio
30
+ const widthRatioCover = containerWidth / currentWidth;
31
+ const heightRatioCover = containerHeight / currentHeight;
32
+ const scaleCover = Math.max(widthRatioCover, heightRatioCover);
33
+ const newWidthCover = currentWidth * scaleCover;
34
+ const newHeightCover = currentHeight * scaleCover;
35
+ // Adjust dimensions if it doesn't cover the container completely
36
+ if (newWidthCover < containerWidth || newHeightCover < containerHeight) {
37
+ const scaleCoverAdjusted = Math.max(
38
+ containerWidth / currentWidth,
39
+ containerHeight / currentHeight,
40
+ );
41
+ newSize = {
42
+ width: currentWidth * scaleCoverAdjusted,
43
+ height: currentHeight * scaleCoverAdjusted,
44
+ };
45
+ break;
46
+ }
47
+ newSize = {
48
+ width: newWidthCover,
49
+ height: newHeightCover,
50
+ };
51
+ break;
52
+ }
53
+ case "fill":
54
+ // Stretch image to fill container
55
+ newSize = {
56
+ width: containerWidth,
57
+ height: containerHeight,
58
+ };
59
+ break;
60
+ case "none":
61
+ // Keep original image size
62
+ newSize = {
63
+ width: currentWidth,
64
+ height: currentHeight,
65
+ };
66
+ break;
67
+ case "scale-down": {
68
+ // Calculate dimensions based on contain and none values
69
+ const widthRatioScaleDown = containerWidth / currentWidth;
70
+ const heightRatioScaleDown = containerHeight / currentHeight;
71
+ const scaleScaleDown = Math.min(
72
+ 1,
73
+ Math.min(widthRatioScaleDown, heightRatioScaleDown),
74
+ );
75
+ newSize = {
76
+ width: currentWidth * scaleScaleDown,
77
+ height: currentHeight * scaleScaleDown,
78
+ };
79
+ break;
80
+ }
81
+ default:
82
+ throw new Error("Invalid object fit value");
83
+ }
84
+
85
+ if (!newSize?.width || !newSize?.height) {
86
+ throw new Error("Invalid dimensions");
87
+ }
88
+
89
+ return {
90
+ width: round2dp(newSize.width),
91
+ height: round2dp(newSize.height),
92
+ };
93
+
94
+ // return newSize;
95
+ };
@@ -0,0 +1,176 @@
1
+ import {
2
+ crop,
3
+ PhotonImage,
4
+ padding_bottom,
5
+ padding_left,
6
+ padding_right,
7
+ padding_top,
8
+ Rgba,
9
+ resize,
10
+ } from "@cf-wasm/photon/node";
11
+ import { ImagePlugin } from "@uploadista/core/flow";
12
+ import { Effect, Layer } from "effect";
13
+ import type tinycolor from "tinycolor2";
14
+ import { calculateImageSize } from "./common";
15
+
16
+ export const tinyColorToPhotonRGBA = (color: tinycolor.Instance) => {
17
+ const rgba = color.toRgb();
18
+ return new Rgba(rgba.r, rgba.g, rgba.b, rgba.a);
19
+ };
20
+
21
+ export const autoResize = (
22
+ image: PhotonImage,
23
+ newWidth?: number,
24
+ newHeight?: number,
25
+ options?: {
26
+ fit?: "contain" | "cover" | "fill";
27
+ fit_cover_letterbox_color?: tinycolor.Instance;
28
+ },
29
+ ) => {
30
+ const currentWidth = image.get_width();
31
+ const currentHeight = image.get_height();
32
+
33
+ if (!newWidth && !newHeight) {
34
+ throw new Error("At least one of width or height is required");
35
+ }
36
+
37
+ if (newWidth && !newHeight) {
38
+ newHeight = Math.floor((newWidth / currentWidth) * currentHeight);
39
+ } else if (newHeight && !newWidth) {
40
+ newWidth = Math.floor((newHeight / currentHeight) * currentWidth);
41
+ }
42
+
43
+ if (!newWidth || !newHeight) {
44
+ throw new Error("Invalid width or height");
45
+ }
46
+
47
+ if (newWidth === currentWidth && newHeight === currentHeight) {
48
+ return image;
49
+ }
50
+
51
+ const fit = options?.fit || "cover";
52
+
53
+ const dem = calculateImageSize(
54
+ fit,
55
+ currentWidth,
56
+ currentHeight,
57
+ newWidth,
58
+ newHeight,
59
+ );
60
+
61
+ image = resize(image, dem.width, dem.height, 1);
62
+
63
+ const [updatedWidth, updatedHeight] = [image.get_width(), image.get_height()];
64
+
65
+ if (fit === "contain" && options?.fit_cover_letterbox_color) {
66
+ const paddingX = Math.floor((newWidth - updatedWidth) / 2);
67
+ const paddingY = Math.floor((newHeight - updatedHeight) / 2);
68
+
69
+ if (paddingY > 0) {
70
+ image = padding_top(
71
+ image,
72
+ paddingY,
73
+ tinyColorToPhotonRGBA(options.fit_cover_letterbox_color),
74
+ );
75
+ image = padding_bottom(
76
+ image,
77
+ paddingY,
78
+ tinyColorToPhotonRGBA(options.fit_cover_letterbox_color),
79
+ );
80
+ }
81
+
82
+ if (paddingX > 0) {
83
+ image = padding_left(
84
+ image,
85
+ paddingX,
86
+ tinyColorToPhotonRGBA(options.fit_cover_letterbox_color),
87
+ );
88
+ image = padding_right(
89
+ image,
90
+ paddingX,
91
+ tinyColorToPhotonRGBA(options.fit_cover_letterbox_color),
92
+ );
93
+ }
94
+ } else if (fit === "cover") {
95
+ //crop to center
96
+ const cropX = Math.floor(updatedWidth - newWidth) / 2;
97
+ const cropY = Math.floor(updatedHeight - newHeight) / 2;
98
+
99
+ //top left to down right
100
+ image = crop(image, cropX, cropY, newWidth + cropX, newHeight + cropY);
101
+ }
102
+
103
+ return image;
104
+ };
105
+
106
+ export const imageToFormat = (
107
+ image: PhotonImage,
108
+ format: "webp" | "jpeg" | "png",
109
+ options?: {
110
+ jpeg_quality?: number;
111
+ },
112
+ ) => {
113
+ let outputBytes: Uint8Array;
114
+
115
+ switch (format) {
116
+ case "webp":
117
+ outputBytes = image.get_bytes_webp();
118
+ break;
119
+ case "jpeg":
120
+ outputBytes = image.get_bytes_jpeg(options?.jpeg_quality || 100);
121
+ break;
122
+ case "png":
123
+ outputBytes = image.get_bytes();
124
+ break;
125
+ default:
126
+ outputBytes = image.get_bytes_jpeg(options?.jpeg_quality || 100);
127
+ }
128
+
129
+ return outputBytes;
130
+ };
131
+
132
+ export const imagePluginNode = Layer.succeed(
133
+ ImagePlugin,
134
+ ImagePlugin.of({
135
+ optimize: (inputBytes, { quality }) => {
136
+ // create a PhotonImage instance
137
+ const inputImage = PhotonImage.new_from_byteslice(inputBytes);
138
+
139
+ // get jpeg bytes
140
+ const outputBytes = inputImage.get_bytes_jpeg(quality);
141
+
142
+ // call free() method to free memory
143
+ inputImage.free();
144
+
145
+ return Effect.succeed(outputBytes);
146
+ },
147
+ resize: (inputBytes, { width, height, fit }) => {
148
+ if (!width && !height) {
149
+ throw new Error("Either width or height must be specified for resize");
150
+ }
151
+ // create a PhotonImage instance
152
+ const inputImage = PhotonImage.new_from_byteslice(inputBytes);
153
+
154
+ // resize image using photon
155
+ const outputImage = autoResize(
156
+ inputImage,
157
+ width ?? inputImage.get_width(),
158
+ height ?? inputImage.get_height(),
159
+ { fit },
160
+ );
161
+
162
+ // get webp bytes
163
+ const outputBytes = outputImage.get_bytes_webp();
164
+
165
+ // for other formats
166
+ // png : outputImage.get_bytes();
167
+ // jpeg : outputImage.get_bytes_jpeg(quality);
168
+
169
+ // call free() method to free memory
170
+ inputImage.free();
171
+ outputImage.free();
172
+
173
+ return Effect.succeed(outputBytes);
174
+ },
175
+ }),
176
+ );
@@ -0,0 +1,150 @@
1
+ import {
2
+ crop,
3
+ PhotonImage,
4
+ padding_bottom,
5
+ padding_left,
6
+ padding_right,
7
+ padding_top,
8
+ Rgba,
9
+ resize,
10
+ } from "@cf-wasm/photon";
11
+ import { ImagePlugin } from "@uploadista/core/flow";
12
+ import { Effect, Layer } from "effect";
13
+ import type tinycolor from "tinycolor2";
14
+ import { calculateImageSize } from "./common";
15
+
16
+ const tinyColorToPhotonRGBA = (color: tinycolor.Instance) => {
17
+ const rgba = color.toRgb();
18
+ return new Rgba(rgba.r, rgba.g, rgba.b, rgba.a);
19
+ };
20
+
21
+ const autoResize = (
22
+ image: PhotonImage,
23
+ newWidth?: number,
24
+ newHeight?: number,
25
+ options?: {
26
+ fit?: "contain" | "cover" | "fill";
27
+ fit_cover_letterbox_color?: tinycolor.Instance;
28
+ },
29
+ ) => {
30
+ const currentWidth = image.get_width();
31
+ const currentHeight = image.get_height();
32
+
33
+ if (!newWidth && !newHeight) {
34
+ throw new Error("At least one of width or height is required");
35
+ }
36
+
37
+ if (newWidth && !newHeight) {
38
+ newHeight = Math.floor((newWidth / currentWidth) * currentHeight);
39
+ } else if (newHeight && !newWidth) {
40
+ newWidth = Math.floor((newHeight / currentHeight) * currentWidth);
41
+ }
42
+
43
+ if (!newWidth || !newHeight) {
44
+ throw new Error("Invalid width or height");
45
+ }
46
+
47
+ if (newWidth === currentWidth && newHeight === currentHeight) {
48
+ return image;
49
+ }
50
+
51
+ const fit = options?.fit || "cover";
52
+
53
+ const dem = calculateImageSize(
54
+ fit,
55
+ currentWidth,
56
+ currentHeight,
57
+ newWidth,
58
+ newHeight,
59
+ );
60
+
61
+ image = resize(image, dem.width, dem.height, 1);
62
+
63
+ const [updatedWidth, updatedHeight] = [image.get_width(), image.get_height()];
64
+
65
+ if (fit === "contain" && options?.fit_cover_letterbox_color) {
66
+ const paddingX = Math.floor((newWidth - updatedWidth) / 2);
67
+ const paddingY = Math.floor((newHeight - updatedHeight) / 2);
68
+
69
+ if (paddingY > 0) {
70
+ image = padding_top(
71
+ image,
72
+ paddingY,
73
+ tinyColorToPhotonRGBA(options.fit_cover_letterbox_color),
74
+ );
75
+ image = padding_bottom(
76
+ image,
77
+ paddingY,
78
+ tinyColorToPhotonRGBA(options.fit_cover_letterbox_color),
79
+ );
80
+ }
81
+
82
+ if (paddingX > 0) {
83
+ image = padding_left(
84
+ image,
85
+ paddingX,
86
+ tinyColorToPhotonRGBA(options.fit_cover_letterbox_color),
87
+ );
88
+ image = padding_right(
89
+ image,
90
+ paddingX,
91
+ tinyColorToPhotonRGBA(options.fit_cover_letterbox_color),
92
+ );
93
+ }
94
+ } else if (fit === "cover") {
95
+ //crop to center
96
+ const cropX = Math.floor(updatedWidth - newWidth) / 2;
97
+ const cropY = Math.floor(updatedHeight - newHeight) / 2;
98
+
99
+ //top left to down right
100
+ image = crop(image, cropX, cropY, newWidth + cropX, newHeight + cropY);
101
+ }
102
+
103
+ return image;
104
+ };
105
+
106
+ export const imagePluginServerless = Layer.succeed(
107
+ ImagePlugin,
108
+ ImagePlugin.of({
109
+ optimize: (inputBytes, { quality }) => {
110
+ // create a PhotonImage instance
111
+ const inputImage = PhotonImage.new_from_byteslice(inputBytes);
112
+
113
+ // get jpeg bytes
114
+ const outputBytes = inputImage.get_bytes_jpeg(quality);
115
+
116
+ // call free() method to free memory
117
+ inputImage.free();
118
+
119
+ return Effect.succeed(outputBytes);
120
+ },
121
+ resize: (inputBytes, { width, height, fit }) => {
122
+ if (!width && !height) {
123
+ throw new Error("Either width or height must be specified for resize");
124
+ }
125
+ // create a PhotonImage instance
126
+ const inputImage = PhotonImage.new_from_byteslice(inputBytes);
127
+
128
+ // resize image using photon
129
+ const outputImage = autoResize(
130
+ inputImage,
131
+ width ?? inputImage.get_width(),
132
+ height ?? inputImage.get_height(),
133
+ { fit },
134
+ );
135
+
136
+ // get webp bytes
137
+ const outputBytes = outputImage.get_bytes_webp();
138
+
139
+ // for other formats
140
+ // png : outputImage.get_bytes();
141
+ // jpeg : outputImage.get_bytes_jpeg(quality);
142
+
143
+ // call free() method to free memory
144
+ inputImage.free();
145
+ outputImage.free();
146
+
147
+ return Effect.succeed(outputBytes);
148
+ },
149
+ }),
150
+ );
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "@uploadista/typescript-config/server.json",
3
+ "compilerOptions": {
4
+ "baseUrl": "./",
5
+ "paths": {
6
+ "@/*": ["./src/*"]
7
+ },
8
+ "outDir": "./dist",
9
+ "rootDir": "./src",
10
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
11
+ "types": []
12
+ },
13
+ "include": ["src"]
14
+ }
@@ -0,0 +1 @@
1
+ {"root":["./src/common.ts","./src/image-plugin-node.ts","./src/image-plugin-serverless.ts"],"version":"5.9.3"}