@narumitw/pi-webui 0.20.2

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/src/images.ts ADDED
@@ -0,0 +1,221 @@
1
+ import type { ImageContent } from "@earendil-works/pi-ai";
2
+ import sharp, { type Metadata, type Sharp } from "sharp";
3
+
4
+ export const DEFAULT_IMAGE_LIMITS = {
5
+ maxImages: 8,
6
+ maxImageBytes: 10 * 1024 * 1024,
7
+ maxPromptBytes: 40 * 1024 * 1024,
8
+ maxPixels: 50_000_000,
9
+ maxDimension: 2_000,
10
+ maxBase64Bytes: 4_500_000,
11
+ } as const;
12
+
13
+ export interface BrowserImageInput {
14
+ name?: string;
15
+ mimeType?: string;
16
+ data: string;
17
+ }
18
+
19
+ export interface ProcessBrowserImageOptions {
20
+ maxImages?: number;
21
+ maxImageBytes?: number;
22
+ maxPromptBytes?: number;
23
+ maxPixels?: number;
24
+ maxDimension?: number;
25
+ maxBase64Bytes?: number;
26
+ autoResize?: boolean;
27
+ blockImages?: boolean;
28
+ supportsImages?: boolean;
29
+ signal?: AbortSignal;
30
+ }
31
+
32
+ type SupportedFormat = "png" | "jpeg" | "webp" | "gif";
33
+
34
+ export async function processBrowserImages(
35
+ inputs: BrowserImageInput[],
36
+ options: ProcessBrowserImageOptions = {},
37
+ ): Promise<ImageContent[]> {
38
+ const limits = { ...DEFAULT_IMAGE_LIMITS, ...definedLimits(options) };
39
+ if (options.blockImages) throw new Error("Pi image sending is disabled.");
40
+ if (options.supportsImages === false)
41
+ throw new Error("The current model does not support images.");
42
+ if (inputs.length > limits.maxImages)
43
+ throw new Error(`Too many images; maximum is ${limits.maxImages}.`);
44
+ assertNotAborted(options.signal);
45
+
46
+ const decoded = inputs.map((input, index) => decodeInput(input, index, limits.maxImageBytes));
47
+ const totalSourceBytes = decoded.reduce((total, item) => total + item.bytes.byteLength, 0);
48
+ if (totalSourceBytes > limits.maxPromptBytes) {
49
+ throw new Error("Combined image input is too large.");
50
+ }
51
+
52
+ const output: ImageContent[] = [];
53
+ let totalOutputBytes = 0;
54
+ for (const item of decoded) {
55
+ assertNotAborted(options.signal);
56
+ const processed = await processOne(
57
+ item.bytes,
58
+ item.format,
59
+ limits,
60
+ options.autoResize !== false,
61
+ options.signal,
62
+ );
63
+ totalOutputBytes += processed.byteLength;
64
+ if (totalOutputBytes > limits.maxPromptBytes)
65
+ throw new Error("Combined processed images are too large.");
66
+ const data = processed.toString("base64");
67
+ if (data.length > limits.maxBase64Bytes)
68
+ throw new Error("Processed image exceeds Pi's inline limit.");
69
+ output.push({ type: "image", data, mimeType: mimeFor(item.format) });
70
+ }
71
+ return output;
72
+ }
73
+
74
+ function definedLimits(options: ProcessBrowserImageOptions): Partial<typeof DEFAULT_IMAGE_LIMITS> {
75
+ const result: Record<string, number> = {};
76
+ for (const key of [
77
+ "maxImages",
78
+ "maxImageBytes",
79
+ "maxPromptBytes",
80
+ "maxPixels",
81
+ "maxDimension",
82
+ "maxBase64Bytes",
83
+ ] as const) {
84
+ const value = options[key];
85
+ if (value === undefined) continue;
86
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`Invalid image limit: ${key}`);
87
+ result[key] = value;
88
+ }
89
+ return result;
90
+ }
91
+
92
+ function decodeInput(
93
+ input: BrowserImageInput,
94
+ index: number,
95
+ maxImageBytes: number,
96
+ ): { bytes: Buffer; format: SupportedFormat } {
97
+ if (!input || typeof input.data !== "string")
98
+ throw new Error(`Image ${index + 1} has invalid data.`);
99
+ if (!input.data) throw new Error(`Image ${index + 1} is empty.`);
100
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(input.data)) {
101
+ throw new Error(`Image ${index + 1} has invalid Base64 data.`);
102
+ }
103
+ const estimatedBytes = Math.floor((input.data.length * 3) / 4);
104
+ if (estimatedBytes > maxImageBytes) throw new Error(`Image ${index + 1} is too large.`);
105
+ const bytes = Buffer.from(input.data, "base64");
106
+ if (bytes.byteLength === 0) throw new Error(`Image ${index + 1} is empty.`);
107
+ if (bytes.byteLength > maxImageBytes) throw new Error(`Image ${index + 1} is too large.`);
108
+ const format = detectFormat(bytes);
109
+ if (!format) throw new Error(`Image ${index + 1} uses an unsupported format.`);
110
+ return { bytes, format };
111
+ }
112
+
113
+ async function processOne(
114
+ bytes: Buffer,
115
+ format: SupportedFormat,
116
+ limits: typeof DEFAULT_IMAGE_LIMITS,
117
+ autoResize: boolean,
118
+ signal?: AbortSignal,
119
+ ): Promise<Buffer> {
120
+ assertNotAborted(signal);
121
+ const metadata = await sharp(bytes, {
122
+ animated: format === "gif",
123
+ limitInputPixels: limits.maxPixels,
124
+ }).metadata();
125
+ assertNotAborted(signal);
126
+ const { width, height } = validatedDimensions(metadata, limits.maxPixels);
127
+ const oversized = width > limits.maxDimension || height > limits.maxDimension;
128
+ if (oversized && !autoResize) throw new Error("Image dimensions exceed Pi's limit.");
129
+
130
+ let targetWidth = oversized ? Math.min(width, limits.maxDimension) : width;
131
+ let targetHeight = oversized ? Math.min(height, limits.maxDimension) : height;
132
+ const ratio = Math.min(targetWidth / width, targetHeight / height, 1);
133
+ targetWidth = Math.max(1, Math.round(width * ratio));
134
+ targetHeight = Math.max(1, Math.round(height * ratio));
135
+
136
+ for (let attempt = 0; attempt < 6; attempt += 1) {
137
+ assertNotAborted(signal);
138
+ let pipeline = sharp(bytes, {
139
+ animated: format === "gif",
140
+ limitInputPixels: limits.maxPixels,
141
+ }).autoOrient();
142
+ if (targetWidth !== width || targetHeight !== height) {
143
+ pipeline = pipeline.resize({
144
+ width: targetWidth,
145
+ height: targetHeight,
146
+ fit: "inside",
147
+ withoutEnlargement: true,
148
+ });
149
+ }
150
+ pipeline = encode(pipeline, format);
151
+ const output = await pipeline.toBuffer();
152
+ assertNotAborted(signal);
153
+ if (output.toString("base64").length <= limits.maxBase64Bytes) return output;
154
+ if (!autoResize) throw new Error("Processed image exceeds Pi's inline limit.");
155
+ targetWidth = Math.max(1, Math.floor(targetWidth * 0.75));
156
+ targetHeight = Math.max(1, Math.floor(targetHeight * 0.75));
157
+ }
158
+ throw new Error("Processed image exceeds Pi's inline limit after resizing.");
159
+ }
160
+
161
+ function validatedDimensions(
162
+ metadata: Metadata,
163
+ maxPixels: number,
164
+ ): { width: number; height: number } {
165
+ const width = metadata.autoOrient.width ?? metadata.width;
166
+ const totalHeight = metadata.autoOrient.height ?? metadata.height;
167
+ if (!width || !totalHeight) throw new Error("Image dimensions could not be decoded.");
168
+ const pages = metadata.pages ?? 1;
169
+ const height = metadata.pageHeight ?? Math.floor(totalHeight / pages);
170
+ if (height <= 0 || width * height * pages > maxPixels) {
171
+ throw new Error("Image pixel count exceeds the limit.");
172
+ }
173
+ return { width, height };
174
+ }
175
+
176
+ function encode(image: Sharp, format: SupportedFormat): Sharp {
177
+ switch (format) {
178
+ case "png":
179
+ return image.png();
180
+ case "jpeg":
181
+ return image.jpeg({ quality: 88, mozjpeg: true });
182
+ case "webp":
183
+ return image.webp({ quality: 88 });
184
+ case "gif":
185
+ return image.gif();
186
+ }
187
+ }
188
+
189
+ function detectFormat(bytes: Uint8Array): SupportedFormat | undefined {
190
+ if (
191
+ bytes.length >= 8 &&
192
+ bytes[0] === 0x89 &&
193
+ bytes[1] === 0x50 &&
194
+ bytes[2] === 0x4e &&
195
+ bytes[3] === 0x47 &&
196
+ bytes[4] === 0x0d &&
197
+ bytes[5] === 0x0a &&
198
+ bytes[6] === 0x1a &&
199
+ bytes[7] === 0x0a
200
+ )
201
+ return "png";
202
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
203
+ return "jpeg";
204
+ if (
205
+ bytes.length >= 12 &&
206
+ Buffer.from(bytes.subarray(0, 4)).toString("ascii") === "RIFF" &&
207
+ Buffer.from(bytes.subarray(8, 12)).toString("ascii") === "WEBP"
208
+ )
209
+ return "webp";
210
+ if (bytes.length >= 6 && Buffer.from(bytes.subarray(0, 4)).toString("ascii") === "GIF8")
211
+ return "gif";
212
+ return undefined;
213
+ }
214
+
215
+ function mimeFor(format: SupportedFormat): string {
216
+ return format === "jpeg" ? "image/jpeg" : `image/${format}`;
217
+ }
218
+
219
+ function assertNotAborted(signal?: AbortSignal): void {
220
+ if (signal?.aborted) throw new Error("Image processing aborted.");
221
+ }
@@ -0,0 +1,74 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
4
+
5
+ export interface EffectivePiImageSettings {
6
+ autoResize: boolean;
7
+ blockImages: boolean;
8
+ warnings: string[];
9
+ }
10
+
11
+ interface ImageSettingsPatch {
12
+ autoResize?: boolean;
13
+ blockImages?: boolean;
14
+ }
15
+
16
+ export async function readEffectivePiImageSettings(
17
+ cwd: string,
18
+ projectTrusted: boolean,
19
+ ): Promise<EffectivePiImageSettings> {
20
+ const warnings: string[] = [];
21
+ const global = await readPatch(join(getAgentDir(), "settings.json"), warnings);
22
+ const project = projectTrusted
23
+ ? await readPatch(join(cwd, CONFIG_DIR_NAME, "settings.json"), warnings)
24
+ : undefined;
25
+ return {
26
+ autoResize: project?.autoResize ?? global?.autoResize ?? true,
27
+ blockImages: project?.blockImages ?? global?.blockImages ?? false,
28
+ warnings,
29
+ };
30
+ }
31
+
32
+ async function readPatch(
33
+ path: string,
34
+ warnings: string[],
35
+ ): Promise<ImageSettingsPatch | undefined> {
36
+ let text: string;
37
+ try {
38
+ text = await readFile(path, "utf8");
39
+ } catch (error) {
40
+ if (isNodeError(error) && error.code === "ENOENT") return undefined;
41
+ warnings.push(`Could not read Pi image settings from ${path}: ${formatError(error)}`);
42
+ return undefined;
43
+ }
44
+ try {
45
+ const parsed = JSON.parse(text) as unknown;
46
+ if (!isRecord(parsed) || !isRecord(parsed.images)) return undefined;
47
+ const patch: ImageSettingsPatch = {};
48
+ if (typeof parsed.images.autoResize === "boolean") patch.autoResize = parsed.images.autoResize;
49
+ else if (parsed.images.autoResize !== undefined) {
50
+ warnings.push(`Ignored non-boolean images.autoResize in ${path}.`);
51
+ }
52
+ if (typeof parsed.images.blockImages === "boolean")
53
+ patch.blockImages = parsed.images.blockImages;
54
+ else if (parsed.images.blockImages !== undefined) {
55
+ warnings.push(`Ignored non-boolean images.blockImages in ${path}.`);
56
+ }
57
+ return patch;
58
+ } catch (error) {
59
+ warnings.push(`Could not parse Pi image settings from ${path}: ${formatError(error)}`);
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === "object" && value !== null && !Array.isArray(value);
66
+ }
67
+
68
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
69
+ return error instanceof Error && "code" in error;
70
+ }
71
+
72
+ function formatError(error: unknown): string {
73
+ return error instanceof Error ? error.message : String(error);
74
+ }