@narumitw/pi-webui 0.21.0 → 0.25.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/src/images.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  import type { ImageContent } from "@earendil-works/pi-ai";
2
- import sharp, { type Metadata, type Sharp } from "sharp";
2
+ import { DEFAULT_IMAGE_LIMITS, type ImageLimits, PROVIDER_IMAGE_LIMITS } from "./image-limits.js";
3
+ import {
4
+ detectImageFormat,
5
+ type ProcessedBrowserImage,
6
+ type ProcessImageOptions,
7
+ processImage,
8
+ } from "./image-pipeline.js";
3
9
 
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;
10
+ export { DEFAULT_IMAGE_LIMITS, PROVIDER_IMAGE_LIMITS } from "./image-limits.js";
11
+ export type { ProcessedBrowserImage } from "./image-pipeline.js";
12
+
13
+ export { detectImageFormat } from "./image-pipeline.js";
12
14
 
13
15
  export interface BrowserImageInput {
14
16
  name?: string;
@@ -29,13 +31,124 @@ export interface ProcessBrowserImageOptions {
29
31
  signal?: AbortSignal;
30
32
  }
31
33
 
32
- type SupportedFormat = "png" | "jpeg" | "webp" | "gif";
34
+ interface ImageProcessorOptions {
35
+ autoResize: boolean;
36
+ maxPixels: number;
37
+ maxDimension?: number;
38
+ maxBase64Bytes?: number;
39
+ signal?: AbortSignal;
40
+ }
41
+
42
+ type ImageProcessFunction = (
43
+ source: Uint8Array,
44
+ options: ProcessImageOptions,
45
+ ) => Promise<ImageContent>;
46
+
47
+ interface QueuedImageProcess {
48
+ source: Uint8Array;
49
+ options: ProcessImageOptions;
50
+ resolve: (result: ImageContent) => void;
51
+ reject: (error: unknown) => void;
52
+ removeAbortListener?: () => void;
53
+ }
54
+
55
+ export class ImageProcessor {
56
+ private readonly queue: QueuedImageProcess[] = [];
57
+ private active = 0;
58
+
59
+ constructor(
60
+ private readonly concurrency = 2,
61
+ private readonly processor: ImageProcessFunction = processProviderImage,
62
+ ) {
63
+ if (!Number.isSafeInteger(concurrency) || concurrency <= 0) {
64
+ throw new Error("Image processing concurrency must be a positive integer");
65
+ }
66
+ }
67
+
68
+ process(source: Uint8Array, options: ImageProcessorOptions): Promise<ImageContent> {
69
+ const resolved: ProcessImageOptions = {
70
+ ...options,
71
+ maxDimension: options.maxDimension ?? PROVIDER_IMAGE_LIMITS.maxDimension,
72
+ maxBase64Bytes: options.maxBase64Bytes ?? PROVIDER_IMAGE_LIMITS.maxBase64Bytes,
73
+ };
74
+ assertNotAborted(resolved.signal);
75
+ return new Promise((resolve, reject) => {
76
+ const job: QueuedImageProcess = { source, options: resolved, resolve, reject };
77
+ if (resolved.signal) {
78
+ const onAbort = () => {
79
+ const index = this.queue.indexOf(job);
80
+ if (index === -1) return;
81
+ this.queue.splice(index, 1);
82
+ job.removeAbortListener?.();
83
+ reject(abortError());
84
+ };
85
+ resolved.signal.addEventListener("abort", onAbort, { once: true });
86
+ job.removeAbortListener = () => resolved.signal?.removeEventListener("abort", onAbort);
87
+ }
88
+ this.queue.push(job);
89
+ this.pump();
90
+ });
91
+ }
92
+
93
+ private pump(): void {
94
+ while (this.active < this.concurrency && this.queue.length > 0) {
95
+ const job = this.queue.shift();
96
+ if (!job) return;
97
+ job.removeAbortListener?.();
98
+ if (job.options.signal?.aborted) {
99
+ job.reject(abortError());
100
+ continue;
101
+ }
102
+ this.active += 1;
103
+ void this.processor(job.source, job.options)
104
+ .then(job.resolve, job.reject)
105
+ .finally(() => {
106
+ this.active -= 1;
107
+ this.pump();
108
+ });
109
+ }
110
+ }
111
+ }
112
+
113
+ export function validateStagedBrowserImages(
114
+ inputs: readonly BrowserImageInput[],
115
+ limits: Readonly<ImageLimits> = DEFAULT_IMAGE_LIMITS,
116
+ ): void {
117
+ if (inputs.length > limits.maxImages) {
118
+ throw new Error(`Too many images; maximum is ${limits.maxImages}.`);
119
+ }
120
+ let total = 0;
121
+ for (const [index, input] of inputs.entries()) {
122
+ total += decodeInput(input, index, PROVIDER_IMAGE_LIMITS.maxBase64Bytes, false).byteLength;
123
+ if (total > limits.maxBatchBytes) throw new Error("Combined image input is too large.");
124
+ }
125
+ }
126
+
127
+ export async function processStagedImage(
128
+ source: Uint8Array,
129
+ options: ProcessBrowserImageOptions = {},
130
+ ): Promise<ProcessedBrowserImage> {
131
+ const limits = resolvedLimits(options);
132
+ if (options.blockImages) throw new Error("Pi image sending is disabled.");
133
+ if (options.supportsImages === false)
134
+ throw new Error("The current model does not support images.");
135
+ assertNotAborted(options.signal);
136
+ if (source.byteLength === 0) throw new Error("Image is empty.");
137
+ if (source.byteLength > limits.maxImageBytes) throw new Error("Image is too large.");
138
+ return processImage(source, {
139
+ autoResize: options.autoResize !== false,
140
+ maxPixels: limits.maxPixels,
141
+ maxDimension: limits.maxDimension,
142
+ maxBase64Bytes: limits.maxBase64Bytes,
143
+ signal: options.signal,
144
+ });
145
+ }
33
146
 
34
147
  export async function processBrowserImages(
35
148
  inputs: BrowserImageInput[],
36
149
  options: ProcessBrowserImageOptions = {},
37
150
  ): Promise<ImageContent[]> {
38
- const limits = { ...DEFAULT_IMAGE_LIMITS, ...definedLimits(options) };
151
+ const limits = resolvedLimits(options);
39
152
  if (options.blockImages) throw new Error("Pi image sending is disabled.");
40
153
  if (options.supportsImages === false)
41
154
  throw new Error("The current model does not support images.");
@@ -44,35 +157,50 @@ export async function processBrowserImages(
44
157
  assertNotAborted(options.signal);
45
158
 
46
159
  const decoded = inputs.map((input, index) => decodeInput(input, index, limits.maxImageBytes));
47
- const totalSourceBytes = decoded.reduce((total, item) => total + item.bytes.byteLength, 0);
160
+ const totalSourceBytes = decoded.reduce((total, item) => total + item.byteLength, 0);
48
161
  if (totalSourceBytes > limits.maxPromptBytes) {
49
162
  throw new Error("Combined image input is too large.");
50
163
  }
51
164
 
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) });
165
+ const processor = new ImageProcessor(2);
166
+ const output = await Promise.all(
167
+ decoded.map((bytes) =>
168
+ processor.process(bytes, {
169
+ autoResize: options.autoResize !== false,
170
+ maxPixels: limits.maxPixels,
171
+ maxDimension: limits.maxDimension,
172
+ maxBase64Bytes: limits.maxBase64Bytes,
173
+ signal: options.signal,
174
+ }),
175
+ ),
176
+ );
177
+ const totalOutputBytes = output.reduce(
178
+ (total, item) => total + Buffer.byteLength(item.data, "base64"),
179
+ 0,
180
+ );
181
+ if (totalOutputBytes > limits.maxPromptBytes) {
182
+ throw new Error("Combined processed images are too large.");
70
183
  }
71
184
  return output;
72
185
  }
73
186
 
74
- function definedLimits(options: ProcessBrowserImageOptions): Partial<typeof DEFAULT_IMAGE_LIMITS> {
75
- const result: Record<string, number> = {};
187
+ function resolvedLimits(options: ProcessBrowserImageOptions): {
188
+ maxImages: number;
189
+ maxImageBytes: number;
190
+ maxPromptBytes: number;
191
+ maxPixels: number;
192
+ maxDimension: number;
193
+ maxBase64Bytes: number;
194
+ } {
195
+ const result = {
196
+ maxImages: DEFAULT_IMAGE_LIMITS.maxImages,
197
+ maxImageBytes: DEFAULT_IMAGE_LIMITS.maxImageBytes,
198
+ maxPromptBytes: DEFAULT_IMAGE_LIMITS.maxBatchBytes,
199
+ maxPixels: DEFAULT_IMAGE_LIMITS.maxImagePixels,
200
+ maxDimension: PROVIDER_IMAGE_LIMITS.maxDimension,
201
+ maxBase64Bytes: PROVIDER_IMAGE_LIMITS.maxBase64Bytes,
202
+ };
203
+ const mutable = result as Record<keyof typeof result, number>;
76
204
  for (const key of [
77
205
  "maxImages",
78
206
  "maxImageBytes",
@@ -84,7 +212,7 @@ function definedLimits(options: ProcessBrowserImageOptions): Partial<typeof DEFA
84
212
  const value = options[key];
85
213
  if (value === undefined) continue;
86
214
  if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`Invalid image limit: ${key}`);
87
- result[key] = value;
215
+ mutable[key] = value;
88
216
  }
89
217
  return result;
90
218
  }
@@ -93,7 +221,8 @@ function decodeInput(
93
221
  input: BrowserImageInput,
94
222
  index: number,
95
223
  maxImageBytes: number,
96
- ): { bytes: Buffer; format: SupportedFormat } {
224
+ requireFormat = true,
225
+ ): Buffer {
97
226
  if (!input || typeof input.data !== "string")
98
227
  throw new Error(`Image ${index + 1} has invalid data.`);
99
228
  if (!input.data) throw new Error(`Image ${index + 1} is empty.`);
@@ -105,117 +234,30 @@ function decodeInput(
105
234
  const bytes = Buffer.from(input.data, "base64");
106
235
  if (bytes.byteLength === 0) throw new Error(`Image ${index + 1} is empty.`);
107
236
  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.");
237
+ if (requireFormat && !detectImageFormat(bytes)) {
238
+ throw new Error(`Image ${index + 1} uses an unsupported format.`);
172
239
  }
173
- return { width, height };
240
+ return bytes;
174
241
  }
175
242
 
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;
243
+ async function processProviderImage(
244
+ source: Uint8Array,
245
+ options: ProcessImageOptions,
246
+ ): Promise<ImageContent> {
247
+ const processed = await processImage(source, options);
248
+ return {
249
+ type: "image",
250
+ data: processed.bytes.toString("base64"),
251
+ mimeType: processed.mimeType,
252
+ };
213
253
  }
214
254
 
215
- function mimeFor(format: SupportedFormat): string {
216
- return format === "jpeg" ? "image/jpeg" : `image/${format}`;
255
+ function assertNotAborted(signal?: AbortSignal): void {
256
+ if (signal?.aborted) throw abortError();
217
257
  }
218
258
 
219
- function assertNotAborted(signal?: AbortSignal): void {
220
- if (signal?.aborted) throw new Error("Image processing aborted.");
259
+ function abortError(): Error {
260
+ const error = new Error("Image processing aborted.");
261
+ error.name = "AbortError";
262
+ return error;
221
263
  }
@@ -43,15 +43,20 @@ async function readPatch(
43
43
  }
44
44
  try {
45
45
  const parsed = JSON.parse(text) as unknown;
46
- if (!isRecord(parsed) || !isRecord(parsed.images)) return undefined;
46
+ if (!isRecord(parsed)) return undefined;
47
+ const images = parsed.images;
48
+ if (images === undefined) return undefined;
49
+ if (!isRecord(images)) {
50
+ warnings.push(`Ignored invalid Pi images settings in ${path}.`);
51
+ return undefined;
52
+ }
47
53
  const patch: ImageSettingsPatch = {};
48
- if (typeof parsed.images.autoResize === "boolean") patch.autoResize = parsed.images.autoResize;
49
- else if (parsed.images.autoResize !== undefined) {
54
+ if (typeof images.autoResize === "boolean") patch.autoResize = images.autoResize;
55
+ else if (images.autoResize !== undefined) {
50
56
  warnings.push(`Ignored non-boolean images.autoResize in ${path}.`);
51
57
  }
52
- if (typeof parsed.images.blockImages === "boolean")
53
- patch.blockImages = parsed.images.blockImages;
54
- else if (parsed.images.blockImages !== undefined) {
58
+ if (typeof images.blockImages === "boolean") patch.blockImages = images.blockImages;
59
+ else if (images.blockImages !== undefined) {
55
60
  warnings.push(`Ignored non-boolean images.blockImages in ${path}.`);
56
61
  }
57
62
  return patch;