@narumitw/pi-image-drop 0.18.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 ADDED
@@ -0,0 +1,408 @@
1
+ import { createHash } from "node:crypto";
2
+ import { decode as decodeBmp } from "bmp-js";
3
+ import decodeHeic from "heic-decode";
4
+ import sharp, { type OutputInfo, type Sharp, type SharpOptions } from "sharp";
5
+ import type { ProcessedImage } from "./batch.js";
6
+
7
+ export type SupportedImageFormat =
8
+ | "png"
9
+ | "jpeg"
10
+ | "webp"
11
+ | "gif"
12
+ | "bmp"
13
+ | "tiff"
14
+ | "heic"
15
+ | "avif";
16
+
17
+ const MIB = 1024 * 1024;
18
+ export const MAX_BASE64_BYTES = 4.5 * MIB;
19
+ const MAX_DIMENSION = 2000;
20
+
21
+ export interface ProcessImageOptions {
22
+ autoResize: boolean;
23
+ maxImagePixels: number;
24
+ signal?: AbortSignal;
25
+ }
26
+
27
+ export class UnsupportedImageError extends Error {}
28
+ export class ImageLimitError extends Error {}
29
+
30
+ interface InputDescriptor {
31
+ input: Buffer;
32
+ options: SharpOptions;
33
+ format: SupportedImageFormat;
34
+ animated: boolean;
35
+ }
36
+
37
+ interface Dimensions {
38
+ width: number;
39
+ height: number;
40
+ pages: number;
41
+ }
42
+
43
+ type ImageProcessFunction = (
44
+ source: Uint8Array,
45
+ options: ProcessImageOptions,
46
+ ) => Promise<ProcessedImage>;
47
+
48
+ interface QueuedImageProcess {
49
+ source: Uint8Array;
50
+ options: ProcessImageOptions;
51
+ resolve: (result: ProcessedImage) => void;
52
+ reject: (error: unknown) => void;
53
+ removeAbortListener?: () => void;
54
+ }
55
+
56
+ export class ImageProcessor {
57
+ private readonly queue: QueuedImageProcess[] = [];
58
+ private active = 0;
59
+
60
+ constructor(
61
+ private readonly concurrency = 2,
62
+ private readonly processor: ImageProcessFunction = processImage,
63
+ ) {
64
+ if (!Number.isSafeInteger(concurrency) || concurrency <= 0) {
65
+ throw new Error("Image processing concurrency must be a positive integer");
66
+ }
67
+ }
68
+
69
+ process(source: Uint8Array, options: ProcessImageOptions): Promise<ProcessedImage> {
70
+ throwIfAborted(options.signal);
71
+ return new Promise((resolve, reject) => {
72
+ const job: QueuedImageProcess = { source, options, resolve, reject };
73
+ if (options.signal) {
74
+ const onAbort = () => {
75
+ const index = this.queue.indexOf(job);
76
+ if (index === -1) return;
77
+ this.queue.splice(index, 1);
78
+ job.removeAbortListener?.();
79
+ reject(abortError());
80
+ };
81
+ options.signal.addEventListener("abort", onAbort, { once: true });
82
+ job.removeAbortListener = () => options.signal?.removeEventListener("abort", onAbort);
83
+ }
84
+ this.queue.push(job);
85
+ this.pump();
86
+ });
87
+ }
88
+
89
+ private pump(): void {
90
+ while (this.active < this.concurrency && this.queue.length > 0) {
91
+ const job = this.queue.shift();
92
+ if (!job) return;
93
+ job.removeAbortListener?.();
94
+ if (job.options.signal?.aborted) {
95
+ job.reject(abortError());
96
+ continue;
97
+ }
98
+ this.active += 1;
99
+ void this.processor(job.source, job.options)
100
+ .then(job.resolve, job.reject)
101
+ .finally(() => {
102
+ this.active -= 1;
103
+ this.pump();
104
+ });
105
+ }
106
+ }
107
+ }
108
+
109
+ export function detectImageFormat(bytes: Uint8Array): SupportedImageFormat | null {
110
+ if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";
111
+ if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "jpeg";
112
+ if (ascii(bytes, 0, 6) === "GIF87a" || ascii(bytes, 0, 6) === "GIF89a") return "gif";
113
+ if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") return "webp";
114
+ if (ascii(bytes, 0, 2) === "BM") return "bmp";
115
+ if (startsWith(bytes, [0x49, 0x49, 0x2a, 0x00]) || startsWith(bytes, [0x4d, 0x4d, 0x00, 0x2a])) {
116
+ return "tiff";
117
+ }
118
+ if (ascii(bytes, 4, 8) === "ftyp") {
119
+ const brands = ascii(bytes, 8, Math.min(bytes.length, 64));
120
+ if (brands.includes("avif") || brands.includes("avis")) return "avif";
121
+ if (
122
+ brands.includes("heic") ||
123
+ brands.includes("heix") ||
124
+ brands.includes("hevc") ||
125
+ brands.includes("hevx") ||
126
+ brands.startsWith("mif1") ||
127
+ brands.startsWith("msf1")
128
+ ) {
129
+ return "heic";
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+
135
+ export async function processImage(
136
+ source: Uint8Array,
137
+ options: ProcessImageOptions,
138
+ ): Promise<ProcessedImage> {
139
+ throwIfAborted(options.signal);
140
+ const bytes = Buffer.from(source);
141
+ const format = detectImageFormat(bytes);
142
+ if (!format) throw new UnsupportedImageError("Unsupported image format");
143
+ const descriptor = await describeInput(bytes, format, options.maxImagePixels, options.signal);
144
+ const original = await dimensions(descriptor, options.maxImagePixels);
145
+ throwIfAborted(options.signal);
146
+
147
+ if (!options.autoResize && (original.width > MAX_DIMENSION || original.height > MAX_DIMENSION)) {
148
+ throw new ImageLimitError("Image exceeds inline image limits while auto-resize is disabled");
149
+ }
150
+
151
+ let target = options.autoResize ? fitWithin(original, MAX_DIMENSION) : original;
152
+ let quality = 95;
153
+ while (true) {
154
+ throwIfAborted(options.signal);
155
+ const encoded = await encode(descriptor, target, quality);
156
+ throwIfAborted(options.signal);
157
+ const base64Bytes = Buffer.byteLength(encoded.data.toString("base64"));
158
+ if (base64Bytes < MAX_BASE64_BYTES) {
159
+ const width = encoded.info.width;
160
+ const height = encoded.info.pageHeight ?? encoded.info.height;
161
+ const resized = width !== original.width || height !== original.height;
162
+ const outputFormat = normalizeOutputFormat(encoded.info.format);
163
+ return {
164
+ bytes: encoded.data,
165
+ mimeType: mimeType(outputFormat),
166
+ width,
167
+ height,
168
+ originalWidth: original.width,
169
+ originalHeight: original.height,
170
+ sourceFormat: format,
171
+ outputFormat,
172
+ resized,
173
+ hash: createHash("sha256").update(encoded.data).digest("hex"),
174
+ notes: buildNotes(format, outputFormat, original, { width, height }, resized),
175
+ };
176
+ }
177
+ if (!options.autoResize) {
178
+ throw new ImageLimitError("Image exceeds inline image limits while auto-resize is disabled");
179
+ }
180
+ if ((format === "jpeg" || format === "webp") && quality > 40) {
181
+ quality = nextQuality(quality);
182
+ continue;
183
+ }
184
+ quality = 95;
185
+ if (target.width === 1 && target.height === 1) {
186
+ throw new ImageLimitError("Image could not be reduced below inline image limits");
187
+ }
188
+ target = {
189
+ ...target,
190
+ width: target.width === 1 ? 1 : Math.max(1, Math.floor(target.width * 0.8)),
191
+ height: target.height === 1 ? 1 : Math.max(1, Math.floor(target.height * 0.8)),
192
+ };
193
+ }
194
+ }
195
+
196
+ async function describeInput(
197
+ bytes: Buffer,
198
+ format: SupportedImageFormat,
199
+ maxImagePixels: number,
200
+ signal?: AbortSignal,
201
+ ): Promise<InputDescriptor> {
202
+ if (format === "bmp") {
203
+ return describeBmp(bytes, maxImagePixels);
204
+ }
205
+ if (format !== "heic") {
206
+ return {
207
+ input: bytes,
208
+ options: {
209
+ animated: format === "gif",
210
+ failOn: "warning",
211
+ limitInputPixels: maxImagePixels,
212
+ sequentialRead: true,
213
+ },
214
+ format,
215
+ animated: format === "gif",
216
+ };
217
+ }
218
+
219
+ throwIfAborted(signal);
220
+ let deferred: Awaited<ReturnType<typeof decodeHeic.all>> | undefined;
221
+ try {
222
+ deferred = await decodeHeic.all({ buffer: bytes });
223
+ const first = deferred[0];
224
+ if (!first) throw new UnsupportedImageError("HEIC image has no frames");
225
+ assertPixelLimit(first.width, first.height, maxImagePixels);
226
+ const decoded = await first.decode();
227
+ throwIfAborted(signal);
228
+ return {
229
+ // Copy pixels out of libheif's WASM heap before deferred.dispose() releases it.
230
+ input: Buffer.from(decoded.data),
231
+ options: {
232
+ raw: { width: decoded.width, height: decoded.height, channels: 4 },
233
+ limitInputPixels: maxImagePixels,
234
+ },
235
+ format,
236
+ animated: false,
237
+ };
238
+ } catch (error) {
239
+ if (error instanceof ImageLimitError || isAbortError(error)) throw error;
240
+ throw new UnsupportedImageError(`HEIC decode failed: ${formatError(error)}`);
241
+ } finally {
242
+ deferred?.dispose();
243
+ }
244
+ }
245
+
246
+ function describeBmp(bytes: Buffer, maxImagePixels: number): InputDescriptor {
247
+ if (bytes.byteLength < 54 || bytes.readUInt32LE(14) < 40) {
248
+ throw new UnsupportedImageError("BMP header is unsupported");
249
+ }
250
+ const width = bytes.readInt32LE(18);
251
+ const signedHeight = bytes.readInt32LE(22);
252
+ const height = Math.abs(signedHeight);
253
+ assertPixelLimit(width, height, maxImagePixels);
254
+ let decoded: ReturnType<typeof decodeBmp>;
255
+ try {
256
+ decoded = decodeBmp(bytes, true);
257
+ } catch (error) {
258
+ throw new UnsupportedImageError(`BMP decode failed: ${formatError(error)}`);
259
+ }
260
+ const rgba = Buffer.allocUnsafe(decoded.width * decoded.height * 4);
261
+ const bitDepth = bytes.readUInt16LE(28);
262
+ for (let offset = 0; offset < rgba.length; offset += 4) {
263
+ rgba[offset] = decoded.data[offset + 3] ?? 0;
264
+ rgba[offset + 1] = decoded.data[offset + 2] ?? 0;
265
+ rgba[offset + 2] = decoded.data[offset + 1] ?? 0;
266
+ rgba[offset + 3] = bitDepth === 32 ? (decoded.data[offset] ?? 255) : 255;
267
+ }
268
+ return {
269
+ input: rgba,
270
+ options: {
271
+ raw: { width: decoded.width, height: decoded.height, channels: 4 },
272
+ limitInputPixels: maxImagePixels,
273
+ },
274
+ format: "bmp",
275
+ animated: false,
276
+ };
277
+ }
278
+
279
+ async function dimensions(
280
+ descriptor: InputDescriptor,
281
+ maxImagePixels: number,
282
+ ): Promise<Dimensions> {
283
+ try {
284
+ const metadata = await sharp(descriptor.input, descriptor.options).metadata();
285
+ const width = metadata.autoOrient.width ?? metadata.width;
286
+ const totalHeight = metadata.autoOrient.height ?? metadata.height;
287
+ if (!width || !totalHeight) throw new Error("Image dimensions are missing");
288
+ const pages = metadata.pages ?? 1;
289
+ const height = metadata.pageHeight ?? Math.floor(totalHeight / pages);
290
+ assertPixelLimit(width, height, maxImagePixels);
291
+ return { width, height, pages };
292
+ } catch (error) {
293
+ if (error instanceof ImageLimitError) throw error;
294
+ throw new Error(`Image decode failed: ${formatError(error)}`);
295
+ }
296
+ }
297
+
298
+ async function encode(
299
+ descriptor: InputDescriptor,
300
+ target: Dimensions,
301
+ quality: number,
302
+ ): Promise<{ data: Buffer; info: OutputInfo }> {
303
+ let pipeline: Sharp = sharp(descriptor.input, descriptor.options).autoOrient().keepIccProfile();
304
+ if (target.width > 0 && target.height > 0) {
305
+ pipeline = pipeline.resize(target.width, target.height, {
306
+ fit: "inside",
307
+ withoutEnlargement: true,
308
+ kernel: sharp.kernel.lanczos3,
309
+ });
310
+ }
311
+ switch (descriptor.format) {
312
+ case "jpeg":
313
+ pipeline = pipeline.jpeg({ quality, chromaSubsampling: "4:4:4", mozjpeg: true });
314
+ break;
315
+ case "webp":
316
+ pipeline = pipeline.webp(
317
+ quality === 95 ? { lossless: true, effort: 5 } : { quality, alphaQuality: 100, effort: 5 },
318
+ );
319
+ break;
320
+ case "gif":
321
+ pipeline = pipeline.gif({ reuse: true, effort: 7 });
322
+ break;
323
+ case "png":
324
+ pipeline = pipeline.png({ compressionLevel: 9, adaptiveFiltering: true });
325
+ break;
326
+ default:
327
+ pipeline = pipeline.png({ compressionLevel: 9, adaptiveFiltering: true });
328
+ }
329
+ return pipeline.toBuffer({ resolveWithObject: true });
330
+ }
331
+
332
+ function fitWithin(dimensions: Dimensions, max: number): Dimensions {
333
+ const ratio = Math.min(1, max / dimensions.width, max / dimensions.height);
334
+ return {
335
+ ...dimensions,
336
+ width: Math.max(1, Math.round(dimensions.width * ratio)),
337
+ height: Math.max(1, Math.round(dimensions.height * ratio)),
338
+ };
339
+ }
340
+
341
+ function buildNotes(
342
+ source: string,
343
+ output: string,
344
+ original: Pick<Dimensions, "width" | "height">,
345
+ result: { width: number; height: number },
346
+ resized: boolean,
347
+ ): string[] {
348
+ const notes: string[] = ["Sensitive image metadata removed"];
349
+ if (source !== output) notes.push(`${source.toUpperCase()} converted to ${output.toUpperCase()}`);
350
+ if (resized) {
351
+ notes.push(`${original.width}×${original.height} resized to ${result.width}×${result.height}`);
352
+ }
353
+ return notes;
354
+ }
355
+
356
+ function nextQuality(quality: number): number {
357
+ if (quality > 85) return 85;
358
+ if (quality > 70) return 70;
359
+ if (quality > 55) return 55;
360
+ return 40;
361
+ }
362
+
363
+ function normalizeOutputFormat(format: string): "png" | "jpeg" | "webp" | "gif" {
364
+ if (format === "jpg") return "jpeg";
365
+ if (format === "png" || format === "jpeg" || format === "webp" || format === "gif") {
366
+ return format;
367
+ }
368
+ throw new UnsupportedImageError(`Unsupported output format: ${format}`);
369
+ }
370
+
371
+ function mimeType(format: "png" | "jpeg" | "webp" | "gif"): string {
372
+ return `image/${format}`;
373
+ }
374
+
375
+ function assertPixelLimit(width: number, height: number, max: number): void {
376
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
377
+ throw new ImageLimitError("Image dimensions are invalid");
378
+ }
379
+ if (width > Math.floor(max / height)) {
380
+ throw new ImageLimitError(`Image exceeds the ${max}-pixel limit`);
381
+ }
382
+ }
383
+
384
+ function throwIfAborted(signal?: AbortSignal): void {
385
+ if (signal?.aborted) throw abortError();
386
+ }
387
+
388
+ function abortError(): Error {
389
+ const error = new Error("Image processing aborted");
390
+ error.name = "AbortError";
391
+ return error;
392
+ }
393
+
394
+ function isAbortError(error: unknown): boolean {
395
+ return error instanceof Error && error.name === "AbortError";
396
+ }
397
+
398
+ function startsWith(bytes: Uint8Array, signature: readonly number[]): boolean {
399
+ return signature.every((byte, index) => bytes[index] === byte);
400
+ }
401
+
402
+ function ascii(bytes: Uint8Array, start: number, end: number): string {
403
+ return Buffer.from(bytes.subarray(start, end)).toString("latin1");
404
+ }
405
+
406
+ function formatError(error: unknown): string {
407
+ return error instanceof Error ? error.message : String(error);
408
+ }
@@ -0,0 +1,79 @@
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)) 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
+ }
53
+ const patch: ImageSettingsPatch = {};
54
+ if (typeof images.autoResize === "boolean") patch.autoResize = images.autoResize;
55
+ else if (images.autoResize !== undefined) {
56
+ warnings.push(`Ignored non-boolean images.autoResize in ${path}.`);
57
+ }
58
+ if (typeof images.blockImages === "boolean") patch.blockImages = images.blockImages;
59
+ else if (images.blockImages !== undefined) {
60
+ warnings.push(`Ignored non-boolean images.blockImages in ${path}.`);
61
+ }
62
+ return patch;
63
+ } catch (error) {
64
+ warnings.push(`Could not parse Pi image settings from ${path}: ${formatError(error)}`);
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ function isRecord(value: unknown): value is Record<string, unknown> {
70
+ return typeof value === "object" && value !== null && !Array.isArray(value);
71
+ }
72
+
73
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
74
+ return error instanceof Error && "code" in error;
75
+ }
76
+
77
+ function formatError(error: unknown) {
78
+ return error instanceof Error ? error.message : String(error);
79
+ }