@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.
- package/.turbo/turbo-build.log +5 -0
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/common.d.ts +6 -0
- package/dist/common.d.ts.map +1 -0
- package/dist/common.js +74 -0
- package/dist/image-plugin-node.d.ts +14 -0
- package/dist/image-plugin-node.d.ts.map +1 -0
- package/dist/image-plugin-node.js +97 -0
- package/dist/image-plugin-serverless.d.ts +4 -0
- package/dist/image-plugin-serverless.d.ts.map +1 -0
- package/dist/image-plugin-serverless.js +80 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/services/image-service-common.d.ts +2 -0
- package/dist/services/image-service-common.d.ts.map +1 -0
- package/dist/services/image-service-common.js +1 -0
- package/dist/services/image-service-node.d.ts +18 -0
- package/dist/services/image-service-node.d.ts.map +1 -0
- package/dist/services/image-service-node.js +170 -0
- package/dist/services/image-service-serverless.d.ts +4 -0
- package/dist/services/image-service-serverless.d.ts.map +1 -0
- package/dist/services/image-service-serverless.js +153 -0
- package/package.json +33 -0
- package/src/common.ts +95 -0
- package/src/image-plugin-node.ts +176 -0
- package/src/image-plugin-serverless.ts +150 -0
- package/tsconfig.json +14 -0
- package/tsconfig.tsbuildinfo +1 -0
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
|
package/dist/common.d.ts
ADDED
|
@@ -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 @@
|
|
|
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
|
+
}));
|
package/dist/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
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 @@
|
|
|
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"}
|