@standardagents/builder 0.9.17 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/built-in-routes.js +306 -4
- package/dist/built-in-routes.js.map +1 -1
- package/dist/client/assets/index.css +1 -1
- package/dist/client/index.js +19 -19
- package/dist/client/vendor.js +1 -1
- package/dist/client/vue.js +1 -1
- package/dist/image-processing.d.ts +41 -0
- package/dist/image-processing.js +198 -0
- package/dist/image-processing.js.map +1 -0
- package/dist/index.d.ts +758 -3
- package/dist/index.js +1342 -37
- package/dist/index.js.map +1 -1
- package/dist/plugin.js +16 -2
- package/dist/plugin.js.map +1 -1
- package/package.json +11 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side image processing for Cloudflare Workers
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Compress/resize images >2MB to fit under 2MB target
|
|
6
|
+
* - Convert AVIF/WebP to JPEG/PNG
|
|
7
|
+
* - Preserve PNG transparency (don't convert transparent PNGs to JPEG)
|
|
8
|
+
* - Smart quality optimization: quality first, dimensions last
|
|
9
|
+
*/
|
|
10
|
+
interface ProcessedImage {
|
|
11
|
+
data: ArrayBuffer;
|
|
12
|
+
mimeType: "image/jpeg" | "image/png";
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Process an image to ensure it's under 2MB and in a supported format.
|
|
18
|
+
*
|
|
19
|
+
* @param input - Raw image data as ArrayBuffer
|
|
20
|
+
* @param inputMimeType - MIME type hint (used as fallback for format detection)
|
|
21
|
+
* @returns Processed image data with updated mimeType and dimensions
|
|
22
|
+
*/
|
|
23
|
+
declare function processImage(input: ArrayBuffer, inputMimeType: string): Promise<ProcessedImage>;
|
|
24
|
+
/**
|
|
25
|
+
* Check if an image needs processing based on size and format.
|
|
26
|
+
*
|
|
27
|
+
* @param data - Base64-encoded image data
|
|
28
|
+
* @param mimeType - MIME type of the image
|
|
29
|
+
* @returns true if processing is needed
|
|
30
|
+
*/
|
|
31
|
+
declare function needsProcessing(data: string, mimeType: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Convert base64 string to ArrayBuffer
|
|
34
|
+
*/
|
|
35
|
+
declare function base64ToArrayBuffer(base64: string): ArrayBuffer;
|
|
36
|
+
/**
|
|
37
|
+
* Convert ArrayBuffer to base64 string
|
|
38
|
+
*/
|
|
39
|
+
declare function arrayBufferToBase64(buffer: ArrayBuffer): string;
|
|
40
|
+
|
|
41
|
+
export { type ProcessedImage, arrayBufferToBase64, base64ToArrayBuffer, needsProcessing, processImage };
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { PhotonImage, resize, SamplingFilter } from '@cf-wasm/photon/workerd';
|
|
2
|
+
import { decode } from '@jsquash/avif';
|
|
3
|
+
import { encode } from '@jsquash/png';
|
|
4
|
+
|
|
5
|
+
// src/image-processing/index.ts
|
|
6
|
+
var MAX_SIZE = 1.5 * 1024 * 1024;
|
|
7
|
+
var MAX_DIMENSION = 8192;
|
|
8
|
+
var MAX_INPUT_SIZE = 20 * 1024 * 1024;
|
|
9
|
+
async function processImage(input, inputMimeType) {
|
|
10
|
+
if (input.byteLength > MAX_INPUT_SIZE) {
|
|
11
|
+
throw new Error(`Image too large: ${input.byteLength} bytes exceeds ${MAX_INPUT_SIZE} byte limit`);
|
|
12
|
+
}
|
|
13
|
+
const format = detectFormat(input, inputMimeType);
|
|
14
|
+
let photonImage;
|
|
15
|
+
if (format === "avif") {
|
|
16
|
+
const imageData = await decode(input);
|
|
17
|
+
photonImage = new PhotonImage(
|
|
18
|
+
new Uint8Array(imageData.data.buffer),
|
|
19
|
+
imageData.width,
|
|
20
|
+
imageData.height
|
|
21
|
+
);
|
|
22
|
+
} else {
|
|
23
|
+
photonImage = PhotonImage.new_from_byteslice(new Uint8Array(input));
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const width = photonImage.get_width();
|
|
27
|
+
const height = photonImage.get_height();
|
|
28
|
+
if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
|
|
29
|
+
const scale = Math.min(MAX_DIMENSION / width, MAX_DIMENSION / height);
|
|
30
|
+
const newWidth = Math.floor(width * scale);
|
|
31
|
+
const newHeight = Math.floor(height * scale);
|
|
32
|
+
const resized = resize(photonImage, newWidth, newHeight, SamplingFilter.Lanczos3);
|
|
33
|
+
photonImage.free();
|
|
34
|
+
photonImage = resized;
|
|
35
|
+
}
|
|
36
|
+
const hasAlpha = detectAlphaChannel(photonImage);
|
|
37
|
+
return await encodeWithSizeLimit(photonImage, hasAlpha, MAX_SIZE);
|
|
38
|
+
} finally {
|
|
39
|
+
photonImage.free();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function detectFormat(data, mimeType) {
|
|
43
|
+
const bytes = new Uint8Array(data.slice(0, 12));
|
|
44
|
+
if (bytes[4] === 102 && bytes[5] === 116 && bytes[6] === 121 && bytes[7] === 112) {
|
|
45
|
+
const brand = String.fromCharCode(...bytes.slice(8, 12));
|
|
46
|
+
if (brand === "avif" || brand === "avis") return "avif";
|
|
47
|
+
}
|
|
48
|
+
if (bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) {
|
|
49
|
+
return "png";
|
|
50
|
+
}
|
|
51
|
+
if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
|
|
52
|
+
return "jpeg";
|
|
53
|
+
}
|
|
54
|
+
if (bytes[0] === 82 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) {
|
|
55
|
+
return "webp";
|
|
56
|
+
}
|
|
57
|
+
if (mimeType.includes("png")) return "png";
|
|
58
|
+
if (mimeType.includes("webp")) return "webp";
|
|
59
|
+
if (mimeType.includes("avif")) return "avif";
|
|
60
|
+
return "jpeg";
|
|
61
|
+
}
|
|
62
|
+
function detectAlphaChannel(image) {
|
|
63
|
+
const rawPixels = image.get_raw_pixels();
|
|
64
|
+
for (let i = 3; i < rawPixels.length; i += 4) {
|
|
65
|
+
if (rawPixels[i] < 255) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
async function encodeWithSizeLimit(image, hasAlpha, maxSize) {
|
|
72
|
+
const originalWidth = image.get_width();
|
|
73
|
+
const originalHeight = image.get_height();
|
|
74
|
+
if (!hasAlpha) {
|
|
75
|
+
const qualityLevels = [92, 85, 75, 65, 55, 45];
|
|
76
|
+
for (const quality of qualityLevels) {
|
|
77
|
+
const encoded3 = image.get_bytes_jpeg(quality);
|
|
78
|
+
if (encoded3.byteLength <= maxSize) {
|
|
79
|
+
return {
|
|
80
|
+
data: encoded3.buffer,
|
|
81
|
+
mimeType: "image/jpeg",
|
|
82
|
+
width: originalWidth,
|
|
83
|
+
height: originalHeight
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
let scale2 = 0.9;
|
|
88
|
+
while (scale2 > 0.15) {
|
|
89
|
+
const newWidth = Math.floor(originalWidth * scale2);
|
|
90
|
+
const newHeight = Math.floor(originalHeight * scale2);
|
|
91
|
+
if (newWidth < 100 || newHeight < 100) {
|
|
92
|
+
scale2 *= 0.9;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const resized = resize(image, newWidth, newHeight, SamplingFilter.Lanczos3);
|
|
96
|
+
for (const quality of qualityLevels) {
|
|
97
|
+
const encoded3 = resized.get_bytes_jpeg(quality);
|
|
98
|
+
if (encoded3.byteLength <= maxSize) {
|
|
99
|
+
const result2 = {
|
|
100
|
+
data: encoded3.buffer,
|
|
101
|
+
mimeType: "image/jpeg",
|
|
102
|
+
width: newWidth,
|
|
103
|
+
height: newHeight
|
|
104
|
+
};
|
|
105
|
+
resized.free();
|
|
106
|
+
return result2;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
resized.free();
|
|
110
|
+
scale2 *= 0.9;
|
|
111
|
+
}
|
|
112
|
+
const finalResized2 = resize(
|
|
113
|
+
image,
|
|
114
|
+
Math.floor(originalWidth * 0.15),
|
|
115
|
+
Math.floor(originalHeight * 0.15),
|
|
116
|
+
SamplingFilter.Lanczos3
|
|
117
|
+
);
|
|
118
|
+
const encoded2 = finalResized2.get_bytes_jpeg(45);
|
|
119
|
+
const result = {
|
|
120
|
+
data: encoded2.buffer,
|
|
121
|
+
mimeType: "image/jpeg",
|
|
122
|
+
width: finalResized2.get_width(),
|
|
123
|
+
height: finalResized2.get_height()
|
|
124
|
+
};
|
|
125
|
+
finalResized2.free();
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
const imageData = image.get_image_data();
|
|
129
|
+
let encoded = await encode(imageData);
|
|
130
|
+
if (encoded.byteLength <= maxSize) {
|
|
131
|
+
return {
|
|
132
|
+
data: encoded,
|
|
133
|
+
mimeType: "image/png",
|
|
134
|
+
width: originalWidth,
|
|
135
|
+
height: originalHeight
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
let scale = 0.9;
|
|
139
|
+
while (encoded.byteLength > maxSize && scale > 0.15) {
|
|
140
|
+
const newWidth = Math.floor(originalWidth * scale);
|
|
141
|
+
const newHeight = Math.floor(originalHeight * scale);
|
|
142
|
+
if (newWidth < 100 || newHeight < 100) {
|
|
143
|
+
scale *= 0.9;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const resized = resize(image, newWidth, newHeight, SamplingFilter.Lanczos3);
|
|
147
|
+
const resizedImageData = resized.get_image_data();
|
|
148
|
+
encoded = await encode(resizedImageData);
|
|
149
|
+
if (encoded.byteLength <= maxSize) {
|
|
150
|
+
const result = {
|
|
151
|
+
data: encoded,
|
|
152
|
+
mimeType: "image/png",
|
|
153
|
+
width: newWidth,
|
|
154
|
+
height: newHeight
|
|
155
|
+
};
|
|
156
|
+
resized.free();
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
resized.free();
|
|
160
|
+
scale *= 0.9;
|
|
161
|
+
}
|
|
162
|
+
const finalWidth = Math.floor(originalWidth * 0.15);
|
|
163
|
+
const finalHeight = Math.floor(originalHeight * 0.15);
|
|
164
|
+
const finalResized = resize(image, finalWidth, finalHeight, SamplingFilter.Lanczos3);
|
|
165
|
+
const finalImageData = finalResized.get_image_data();
|
|
166
|
+
encoded = await encode(finalImageData);
|
|
167
|
+
finalResized.free();
|
|
168
|
+
return {
|
|
169
|
+
data: encoded,
|
|
170
|
+
mimeType: "image/png",
|
|
171
|
+
width: finalWidth,
|
|
172
|
+
height: finalHeight
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function needsProcessing(data, mimeType) {
|
|
176
|
+
const binaryLength = Math.ceil(data.length * 3 / 4);
|
|
177
|
+
return binaryLength > MAX_SIZE || mimeType.includes("avif") || mimeType.includes("webp");
|
|
178
|
+
}
|
|
179
|
+
function base64ToArrayBuffer(base64) {
|
|
180
|
+
const binaryString = atob(base64);
|
|
181
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
182
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
183
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
184
|
+
}
|
|
185
|
+
return bytes.buffer;
|
|
186
|
+
}
|
|
187
|
+
function arrayBufferToBase64(buffer) {
|
|
188
|
+
const bytes = new Uint8Array(buffer);
|
|
189
|
+
let binary = "";
|
|
190
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
191
|
+
binary += String.fromCharCode(bytes[i]);
|
|
192
|
+
}
|
|
193
|
+
return btoa(binary);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export { arrayBufferToBase64, base64ToArrayBuffer, needsProcessing, processImage };
|
|
197
|
+
//# sourceMappingURL=image-processing.js.map
|
|
198
|
+
//# sourceMappingURL=image-processing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/image-processing/index.ts"],"names":["decodeAvif","encoded","scale","result","finalResized","encodePng"],"mappings":"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","file":"image-processing.js","sourcesContent":["/**\n * Server-side image processing for Cloudflare Workers\n *\n * Features:\n * - Compress/resize images >2MB to fit under 2MB target\n * - Convert AVIF/WebP to JPEG/PNG\n * - Preserve PNG transparency (don't convert transparent PNGs to JPEG)\n * - Smart quality optimization: quality first, dimensions last\n */\n\nimport {\n PhotonImage,\n SamplingFilter,\n resize,\n} from \"@cf-wasm/photon/workerd\";\nimport { decode as decodeAvif } from \"@jsquash/avif\";\nimport { encode as encodeJpeg, decode as decodeJpeg } from \"@jsquash/jpeg\";\nimport { encode as encodePng, decode as decodePng } from \"@jsquash/png\";\n\n// Target 1.5MB binary → ~2MB base64 for RPC transfer\n// SQLite limit is 2MB per row, but base64 overhead means we need headroom\nconst MAX_SIZE = 1.5 * 1024 * 1024;\n\n// Memory safety limit - extremely large images can crash Workers (128MB limit)\nconst MAX_DIMENSION = 8192;\n\n// Reject images that would consume too much memory before decoding\nconst MAX_INPUT_SIZE = 20 * 1024 * 1024; // 20MB\n\nexport interface ProcessedImage {\n data: ArrayBuffer;\n mimeType: \"image/jpeg\" | \"image/png\";\n width: number;\n height: number;\n}\n\n/**\n * Process an image to ensure it's under 2MB and in a supported format.\n *\n * @param input - Raw image data as ArrayBuffer\n * @param inputMimeType - MIME type hint (used as fallback for format detection)\n * @returns Processed image data with updated mimeType and dimensions\n */\nexport async function processImage(\n input: ArrayBuffer,\n inputMimeType: string\n): Promise<ProcessedImage> {\n // Memory safety: reject very large images\n if (input.byteLength > MAX_INPUT_SIZE) {\n throw new Error(`Image too large: ${input.byteLength} bytes exceeds ${MAX_INPUT_SIZE} byte limit`);\n }\n\n // Detect format from magic bytes\n const format = detectFormat(input, inputMimeType);\n\n // Decode image to PhotonImage\n let photonImage: PhotonImage;\n\n if (format === \"avif\") {\n // AVIF: decode with jSquash, then convert to PhotonImage\n const imageData = await decodeAvif(input);\n photonImage = new PhotonImage(\n new Uint8Array(imageData.data.buffer),\n imageData.width,\n imageData.height\n );\n } else {\n // JPEG, PNG, WebP: photon can decode these directly\n photonImage = PhotonImage.new_from_byteslice(new Uint8Array(input));\n }\n\n try {\n // Get original dimensions\n const width = photonImage.get_width();\n const height = photonImage.get_height();\n\n // Memory safety: limit extreme dimensions\n if (width > MAX_DIMENSION || height > MAX_DIMENSION) {\n const scale = Math.min(MAX_DIMENSION / width, MAX_DIMENSION / height);\n const newWidth = Math.floor(width * scale);\n const newHeight = Math.floor(height * scale);\n const resized = resize(photonImage, newWidth, newHeight, SamplingFilter.Lanczos3);\n photonImage.free();\n photonImage = resized;\n }\n\n // Check for transparency to decide output format\n const hasAlpha = detectAlphaChannel(photonImage);\n\n // Encode with smart size optimization\n return await encodeWithSizeLimit(photonImage, hasAlpha, MAX_SIZE);\n } finally {\n photonImage.free();\n }\n}\n\n/**\n * Detect image format from magic bytes\n */\nfunction detectFormat(data: ArrayBuffer, mimeType: string): string {\n const bytes = new Uint8Array(data.slice(0, 12));\n\n // AVIF/HEIC: starts with ftyp box\n if (bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) {\n const brand = String.fromCharCode(...bytes.slice(8, 12));\n if (brand === \"avif\" || brand === \"avis\") return \"avif\";\n // Note: HEIC (heic, heix, mif1) not supported - would need separate decoder\n }\n\n // PNG: 89 50 4E 47\n if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) {\n return \"png\";\n }\n\n // JPEG: FF D8 FF\n if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) {\n return \"jpeg\";\n }\n\n // WebP: RIFF....WEBP\n if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&\n bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {\n return \"webp\";\n }\n\n // Fallback to mimeType hint\n if (mimeType.includes(\"png\")) return \"png\";\n if (mimeType.includes(\"webp\")) return \"webp\";\n if (mimeType.includes(\"avif\")) return \"avif\";\n return \"jpeg\";\n}\n\n/**\n * Check if image has transparent pixels\n */\nfunction detectAlphaChannel(image: PhotonImage): boolean {\n const rawPixels = image.get_raw_pixels();\n // RGBA format: check every 4th byte (alpha channel)\n for (let i = 3; i < rawPixels.length; i += 4) {\n if (rawPixels[i] < 255) {\n return true; // Found non-opaque pixel\n }\n }\n return false;\n}\n\n/**\n * Encode image with smart size optimization.\n * Strategy: quality reduction first, dimension reduction last.\n */\nasync function encodeWithSizeLimit(\n image: PhotonImage,\n hasAlpha: boolean,\n maxSize: number\n): Promise<ProcessedImage> {\n const originalWidth = image.get_width();\n const originalHeight = image.get_height();\n\n // For opaque images: use JPEG with quality reduction first\n if (!hasAlpha) {\n // Quality levels to try (minimum 45% to preserve acceptable quality)\n const qualityLevels = [92, 85, 75, 65, 55, 45];\n\n // Try each quality level at original resolution\n for (const quality of qualityLevels) {\n const encoded = image.get_bytes_jpeg(quality);\n if (encoded.byteLength <= maxSize) {\n return {\n data: encoded.buffer,\n mimeType: \"image/jpeg\",\n width: originalWidth,\n height: originalHeight,\n };\n }\n }\n\n // Quality 45 wasn't enough - reduce dimensions iteratively\n // Use fine-grained steps (10% reduction each) to find optimal quality/resolution tradeoff\n let scale = 0.9;\n while (scale > 0.15) {\n const newWidth = Math.floor(originalWidth * scale);\n const newHeight = Math.floor(originalHeight * scale);\n\n // Skip if dimensions haven't changed meaningfully\n if (newWidth < 100 || newHeight < 100) {\n scale *= 0.9;\n continue;\n }\n\n const resized = resize(image, newWidth, newHeight, SamplingFilter.Lanczos3);\n\n for (const quality of qualityLevels) {\n const encoded = resized.get_bytes_jpeg(quality);\n if (encoded.byteLength <= maxSize) {\n const result = {\n data: encoded.buffer,\n mimeType: \"image/jpeg\" as const,\n width: newWidth,\n height: newHeight,\n };\n resized.free();\n return result;\n }\n }\n\n resized.free();\n scale *= 0.9; // 10% reduction per step for finer granularity\n }\n\n // Fallback: return smallest we could achieve\n const finalResized = resize(\n image,\n Math.floor(originalWidth * 0.15),\n Math.floor(originalHeight * 0.15),\n SamplingFilter.Lanczos3\n );\n const encoded = finalResized.get_bytes_jpeg(45);\n const result = {\n data: encoded.buffer,\n mimeType: \"image/jpeg\" as const,\n width: finalResized.get_width(),\n height: finalResized.get_height(),\n };\n finalResized.free();\n return result;\n }\n\n // For transparent images: use PNG (lossless, can only reduce dimensions)\n // First try at original resolution\n const imageData = image.get_image_data();\n let encoded = await encodePng(imageData);\n\n if (encoded.byteLength <= maxSize) {\n return {\n data: encoded,\n mimeType: \"image/png\",\n width: originalWidth,\n height: originalHeight,\n };\n }\n\n // PNG too large - reduce dimensions iteratively (10% reduction per step)\n let scale = 0.9;\n while (encoded.byteLength > maxSize && scale > 0.15) {\n const newWidth = Math.floor(originalWidth * scale);\n const newHeight = Math.floor(originalHeight * scale);\n\n // Skip if dimensions are too small\n if (newWidth < 100 || newHeight < 100) {\n scale *= 0.9;\n continue;\n }\n\n const resized = resize(image, newWidth, newHeight, SamplingFilter.Lanczos3);\n const resizedImageData = resized.get_image_data();\n encoded = await encodePng(resizedImageData);\n\n if (encoded.byteLength <= maxSize) {\n const result = {\n data: encoded,\n mimeType: \"image/png\" as const,\n width: newWidth,\n height: newHeight,\n };\n resized.free();\n return result;\n }\n\n resized.free();\n scale *= 0.9;\n }\n\n // Fallback: return smallest PNG we could achieve\n const finalWidth = Math.floor(originalWidth * 0.15);\n const finalHeight = Math.floor(originalHeight * 0.15);\n const finalResized = resize(image, finalWidth, finalHeight, SamplingFilter.Lanczos3);\n const finalImageData = finalResized.get_image_data();\n encoded = await encodePng(finalImageData);\n finalResized.free();\n\n return {\n data: encoded,\n mimeType: \"image/png\",\n width: finalWidth,\n height: finalHeight,\n };\n}\n\n/**\n * Check if an image needs processing based on size and format.\n *\n * @param data - Base64-encoded image data\n * @param mimeType - MIME type of the image\n * @returns true if processing is needed\n */\nexport function needsProcessing(data: string, mimeType: string): boolean {\n // Decode base64 to get actual size\n const binaryLength = Math.ceil(data.length * 3 / 4);\n\n // Process if >2MB or unsupported format\n return (\n binaryLength > MAX_SIZE ||\n mimeType.includes(\"avif\") ||\n mimeType.includes(\"webp\")\n );\n}\n\n/**\n * Convert base64 string to ArrayBuffer\n */\nexport function base64ToArrayBuffer(base64: string): ArrayBuffer {\n const binaryString = atob(base64);\n const bytes = new Uint8Array(binaryString.length);\n for (let i = 0; i < binaryString.length; i++) {\n bytes[i] = binaryString.charCodeAt(i);\n }\n return bytes.buffer;\n}\n\n/**\n * Convert ArrayBuffer to base64 string\n */\nexport function arrayBufferToBase64(buffer: ArrayBuffer): string {\n const bytes = new Uint8Array(buffer);\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]);\n }\n return btoa(binary);\n}\n"]}
|