@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/README.md +59 -27
- package/package.json +3 -1
- package/src/attachments.ts +715 -0
- package/src/conversation.ts +23 -6
- package/src/drafts.ts +226 -0
- package/src/image-limits.ts +36 -0
- package/src/image-pipeline.ts +325 -0
- package/src/images.ts +182 -140
- package/src/pi-settings.ts +11 -6
- package/src/runtime.ts +138 -34
- package/src/sent-images.ts +279 -0
- package/src/server.ts +490 -30
- package/src/settings.ts +102 -7
- package/src/vendor.d.ts +30 -0
- package/src/web/app.js +558 -100
- package/src/web/image-drag.js +86 -0
- package/src/web/index.html +25 -2
- package/src/web/state.js +178 -8
- package/src/web/styles.css +176 -11
- package/src/web/transcript.js +66 -9
package/src/conversation.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface SessionDescriptor {
|
|
|
12
12
|
export type PublicContent =
|
|
13
13
|
| { type: "text"; text: string }
|
|
14
14
|
| { type: "thinking"; text: string }
|
|
15
|
-
| { type: "image"; mimeType?: string }
|
|
15
|
+
| { type: "image"; mimeType?: string; retainedImageId?: string }
|
|
16
16
|
| { type: "toolCall"; id: string; name: string; arguments: unknown };
|
|
17
17
|
|
|
18
18
|
export interface PublicMessage {
|
|
@@ -116,8 +116,13 @@ export class ConversationProjection {
|
|
|
116
116
|
this.publishSnapshot();
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
recordMessage(
|
|
120
|
-
|
|
119
|
+
recordMessage(
|
|
120
|
+
message: unknown,
|
|
121
|
+
final = true,
|
|
122
|
+
entryId?: string,
|
|
123
|
+
retainedImageIds: readonly string[] = [],
|
|
124
|
+
): void {
|
|
125
|
+
const projected = projectMessage(message, entryId, final, retainedImageIds);
|
|
121
126
|
const previous = this.messages.get(projected.id);
|
|
122
127
|
if (previous && JSON.stringify(previous) === JSON.stringify(projected)) return;
|
|
123
128
|
this.messages.set(projected.id, projected);
|
|
@@ -194,12 +199,17 @@ export function projectBranchMessages(branch: unknown): PublicMessage[] {
|
|
|
194
199
|
return messages;
|
|
195
200
|
}
|
|
196
201
|
|
|
197
|
-
export function projectMessage(
|
|
202
|
+
export function projectMessage(
|
|
203
|
+
message: unknown,
|
|
204
|
+
entryId?: string,
|
|
205
|
+
final = true,
|
|
206
|
+
retainedImageIds: readonly string[] = [],
|
|
207
|
+
): PublicMessage {
|
|
198
208
|
if (!isRecord(message) || typeof message.role !== "string") {
|
|
199
209
|
throw new Error("Invalid Pi message");
|
|
200
210
|
}
|
|
201
211
|
const timestamp = typeof message.timestamp === "number" ? message.timestamp : undefined;
|
|
202
|
-
const content = projectContent(message.content);
|
|
212
|
+
const content = projectContent(message.content, retainedImageIds);
|
|
203
213
|
const id = entryId ?? messageId(message, timestamp, content);
|
|
204
214
|
return {
|
|
205
215
|
id,
|
|
@@ -217,10 +227,14 @@ export function projectMessage(message: unknown, entryId?: string, final = true)
|
|
|
217
227
|
};
|
|
218
228
|
}
|
|
219
229
|
|
|
220
|
-
function projectContent(
|
|
230
|
+
function projectContent(
|
|
231
|
+
content: unknown,
|
|
232
|
+
retainedImageIds: readonly string[] = [],
|
|
233
|
+
): PublicContent[] {
|
|
221
234
|
if (typeof content === "string") return [{ type: "text", text: truncateText(content) }];
|
|
222
235
|
if (!Array.isArray(content)) return [];
|
|
223
236
|
const projected: PublicContent[] = [];
|
|
237
|
+
let imageIndex = 0;
|
|
224
238
|
for (const block of content) {
|
|
225
239
|
if (!isRecord(block) || typeof block.type !== "string") continue;
|
|
226
240
|
if (block.type === "text" && typeof block.text === "string") {
|
|
@@ -228,9 +242,12 @@ function projectContent(content: unknown): PublicContent[] {
|
|
|
228
242
|
} else if (block.type === "thinking" && typeof block.thinking === "string") {
|
|
229
243
|
projected.push({ type: "thinking", text: truncateText(block.thinking) });
|
|
230
244
|
} else if (block.type === "image") {
|
|
245
|
+
const retainedImageId = retainedImageIds[imageIndex];
|
|
246
|
+
imageIndex += 1;
|
|
231
247
|
projected.push({
|
|
232
248
|
type: "image",
|
|
233
249
|
...(typeof block.mimeType === "string" ? { mimeType: block.mimeType } : {}),
|
|
250
|
+
...(typeof retainedImageId === "string" ? { retainedImageId } : {}),
|
|
234
251
|
});
|
|
235
252
|
} else if (
|
|
236
253
|
block.type === "toolCall" &&
|
package/src/drafts.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface DraftSettings {
|
|
4
|
+
maxTextBytes: number;
|
|
5
|
+
maxMutationRecords?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PublicDraftState {
|
|
9
|
+
revision: number;
|
|
10
|
+
text: string;
|
|
11
|
+
attachmentRevision: number;
|
|
12
|
+
attachmentIds: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DraftSendReservation {
|
|
16
|
+
token: string;
|
|
17
|
+
revision: number;
|
|
18
|
+
text: string;
|
|
19
|
+
attachmentRevision: number;
|
|
20
|
+
attachmentIds: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface MutationRecord {
|
|
24
|
+
digest: string;
|
|
25
|
+
result: PublicDraftState;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class DraftError extends Error {
|
|
29
|
+
constructor(
|
|
30
|
+
message: string,
|
|
31
|
+
readonly status = 409,
|
|
32
|
+
) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "DraftError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class DraftStore {
|
|
39
|
+
private readonly mutations = new Map<string, MutationRecord>();
|
|
40
|
+
private readonly reservations = new Map<string, DraftSendReservation>();
|
|
41
|
+
private readonly maxMutationRecords: number;
|
|
42
|
+
private revision = 0;
|
|
43
|
+
private text = "";
|
|
44
|
+
private attachmentRevision = 0;
|
|
45
|
+
private attachmentIds: string[] = [];
|
|
46
|
+
private closed = false;
|
|
47
|
+
|
|
48
|
+
constructor(private readonly settings: DraftSettings) {
|
|
49
|
+
if (!Number.isSafeInteger(settings.maxTextBytes) || settings.maxTextBytes <= 0) {
|
|
50
|
+
throw new Error("Draft maxTextBytes must be a positive integer.");
|
|
51
|
+
}
|
|
52
|
+
this.maxMutationRecords = settings.maxMutationRecords ?? 128;
|
|
53
|
+
if (!Number.isSafeInteger(this.maxMutationRecords) || this.maxMutationRecords <= 0) {
|
|
54
|
+
throw new Error("Draft maxMutationRecords must be a positive integer.");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
publicState(): PublicDraftState {
|
|
59
|
+
return {
|
|
60
|
+
revision: this.revision,
|
|
61
|
+
text: this.text,
|
|
62
|
+
attachmentRevision: this.attachmentRevision,
|
|
63
|
+
attachmentIds: [...this.attachmentIds],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
residentBytes(): number {
|
|
68
|
+
return Buffer.byteLength(this.text);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
setText(text: string, expectedRevision: number, mutationId: string): PublicDraftState {
|
|
72
|
+
this.assertOpen();
|
|
73
|
+
const normalized = normalizeText(text, this.settings.maxTextBytes);
|
|
74
|
+
const id = normalizeMutationId(mutationId);
|
|
75
|
+
const digest = mutationDigest(normalized);
|
|
76
|
+
const prior = this.mutations.get(id);
|
|
77
|
+
if (prior) {
|
|
78
|
+
if (prior.digest !== digest) throw new DraftError("Draft mutation id was reused.");
|
|
79
|
+
return cloneState(prior.result);
|
|
80
|
+
}
|
|
81
|
+
this.assertRevision(expectedRevision);
|
|
82
|
+
if (normalized !== this.text) {
|
|
83
|
+
this.text = normalized;
|
|
84
|
+
this.revision += 1;
|
|
85
|
+
}
|
|
86
|
+
const result = this.publicState();
|
|
87
|
+
this.mutations.set(id, { digest, result: cloneState(result) });
|
|
88
|
+
this.evictMutations();
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
syncAttachments(ids: readonly string[], attachmentRevision: number): PublicDraftState {
|
|
93
|
+
this.assertOpen();
|
|
94
|
+
const normalized = normalizeAttachmentIds(ids);
|
|
95
|
+
if (!Number.isSafeInteger(attachmentRevision) || attachmentRevision < 0) {
|
|
96
|
+
throw new DraftError("Attachment revision is invalid.", 400);
|
|
97
|
+
}
|
|
98
|
+
if (sameIds(normalized, this.attachmentIds)) return this.publicState();
|
|
99
|
+
this.attachmentIds = normalized;
|
|
100
|
+
this.attachmentRevision = attachmentRevision;
|
|
101
|
+
this.revision += 1;
|
|
102
|
+
return this.publicState();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
beginSend(expectedRevision: number): DraftSendReservation {
|
|
106
|
+
this.assertOpen();
|
|
107
|
+
this.assertRevision(expectedRevision);
|
|
108
|
+
if (!this.text.trim() && this.attachmentIds.length === 0) {
|
|
109
|
+
throw new DraftError("Draft cannot be empty.", 400);
|
|
110
|
+
}
|
|
111
|
+
const reservation: DraftSendReservation = {
|
|
112
|
+
token: randomUUID(),
|
|
113
|
+
revision: this.revision,
|
|
114
|
+
text: this.text,
|
|
115
|
+
attachmentRevision: this.attachmentRevision,
|
|
116
|
+
attachmentIds: [...this.attachmentIds],
|
|
117
|
+
};
|
|
118
|
+
this.reservations.set(reservation.token, reservation);
|
|
119
|
+
return cloneReservation(reservation);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
finishSend(
|
|
123
|
+
token: string,
|
|
124
|
+
committed: boolean,
|
|
125
|
+
attachments?: { revision: number; ids: readonly string[] },
|
|
126
|
+
): PublicDraftState {
|
|
127
|
+
this.assertOpen();
|
|
128
|
+
const reservation = this.reservations.get(token);
|
|
129
|
+
if (!reservation) throw new DraftError("Draft send reservation is stale.");
|
|
130
|
+
this.reservations.delete(token);
|
|
131
|
+
if (!committed) return this.publicState();
|
|
132
|
+
let changed = false;
|
|
133
|
+
if (this.text === reservation.text && this.text !== "") {
|
|
134
|
+
this.text = "";
|
|
135
|
+
changed = true;
|
|
136
|
+
}
|
|
137
|
+
if (attachments && sameIds(this.attachmentIds, reservation.attachmentIds)) {
|
|
138
|
+
const normalized = normalizeAttachmentIds(attachments.ids);
|
|
139
|
+
if (!Number.isSafeInteger(attachments.revision) || attachments.revision < 0) {
|
|
140
|
+
throw new DraftError("Attachment revision is invalid.", 400);
|
|
141
|
+
}
|
|
142
|
+
if (
|
|
143
|
+
!sameIds(this.attachmentIds, normalized) ||
|
|
144
|
+
this.attachmentRevision !== attachments.revision
|
|
145
|
+
) {
|
|
146
|
+
this.attachmentIds = normalized;
|
|
147
|
+
this.attachmentRevision = attachments.revision;
|
|
148
|
+
changed = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (changed) this.revision += 1;
|
|
152
|
+
return this.publicState();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
close(): void {
|
|
156
|
+
if (this.closed) return;
|
|
157
|
+
this.closed = true;
|
|
158
|
+
this.text = "";
|
|
159
|
+
this.attachmentIds = [];
|
|
160
|
+
this.attachmentRevision = 0;
|
|
161
|
+
this.mutations.clear();
|
|
162
|
+
this.reservations.clear();
|
|
163
|
+
this.revision += 1;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private assertRevision(expectedRevision: number): void {
|
|
167
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== this.revision) {
|
|
168
|
+
throw new DraftError(`Draft revision mismatch; current revision is ${this.revision}.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private assertOpen(): void {
|
|
173
|
+
if (this.closed) throw new DraftError("Draft store is closed.", 410);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private evictMutations(): void {
|
|
177
|
+
while (this.mutations.size > this.maxMutationRecords) {
|
|
178
|
+
const oldest = this.mutations.keys().next().value;
|
|
179
|
+
if (typeof oldest !== "string") return;
|
|
180
|
+
this.mutations.delete(oldest);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeText(value: string, maxBytes: number): string {
|
|
186
|
+
if (typeof value !== "string") throw new DraftError("Draft text is invalid.", 400);
|
|
187
|
+
if (Buffer.byteLength(value) > maxBytes) throw new DraftError("Draft text is too large.", 413);
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function normalizeMutationId(value: string): string {
|
|
192
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) {
|
|
193
|
+
throw new DraftError("Draft mutation id is invalid.", 400);
|
|
194
|
+
}
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeAttachmentIds(values: readonly string[]): string[] {
|
|
199
|
+
if (!Array.isArray(values)) throw new DraftError("Attachment ids are invalid.", 400);
|
|
200
|
+
const result = values.map((value) => {
|
|
201
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) {
|
|
202
|
+
throw new DraftError("Attachment id is invalid.", 400);
|
|
203
|
+
}
|
|
204
|
+
return value;
|
|
205
|
+
});
|
|
206
|
+
if (new Set(result).size !== result.length) {
|
|
207
|
+
throw new DraftError("Attachment ids contain a duplicate.", 400);
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function mutationDigest(text: string): string {
|
|
213
|
+
return createHash("sha256").update(text).digest("hex");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function sameIds(left: readonly string[], right: readonly string[]): boolean {
|
|
217
|
+
return left.length === right.length && left.every((id, index) => id === right[index]);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function cloneState(state: PublicDraftState): PublicDraftState {
|
|
221
|
+
return { ...state, attachmentIds: [...state.attachmentIds] };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function cloneReservation(reservation: DraftSendReservation): DraftSendReservation {
|
|
225
|
+
return { ...reservation, attachmentIds: [...reservation.attachmentIds] };
|
|
226
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const MIB = 1024 * 1024;
|
|
2
|
+
|
|
3
|
+
export interface ImageLimits {
|
|
4
|
+
maxImages: number;
|
|
5
|
+
maxImageBytes: number;
|
|
6
|
+
maxBatchBytes: number;
|
|
7
|
+
maxImagePixels: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_IMAGE_LIMITS: Readonly<ImageLimits> = Object.freeze({
|
|
11
|
+
maxImages: 8,
|
|
12
|
+
maxImageBytes: 10 * MIB,
|
|
13
|
+
maxBatchBytes: 40 * MIB,
|
|
14
|
+
maxImagePixels: 50_000_000,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const IMAGE_HARD_LIMITS: Readonly<ImageLimits> = Object.freeze({
|
|
18
|
+
maxImages: 32,
|
|
19
|
+
maxImageBytes: 50 * MIB,
|
|
20
|
+
maxBatchBytes: 200 * MIB,
|
|
21
|
+
maxImagePixels: 100_000_000,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const PROVIDER_IMAGE_LIMITS = Object.freeze({
|
|
25
|
+
maxDimension: 2_000,
|
|
26
|
+
maxBase64Bytes: 4_500_000,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export function imageLimits(value: Partial<ImageLimits> = {}): Readonly<ImageLimits> {
|
|
30
|
+
return Object.freeze({
|
|
31
|
+
maxImages: value.maxImages ?? DEFAULT_IMAGE_LIMITS.maxImages,
|
|
32
|
+
maxImageBytes: value.maxImageBytes ?? DEFAULT_IMAGE_LIMITS.maxImageBytes,
|
|
33
|
+
maxBatchBytes: value.maxBatchBytes ?? DEFAULT_IMAGE_LIMITS.maxBatchBytes,
|
|
34
|
+
maxImagePixels: value.maxImagePixels ?? DEFAULT_IMAGE_LIMITS.maxImagePixels,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { decode as decodeBmp } from "bmp-js";
|
|
2
|
+
import decodeHeic from "heic-decode";
|
|
3
|
+
import sharp, { type OutputInfo, type Sharp, type SharpOptions } from "sharp";
|
|
4
|
+
|
|
5
|
+
export type SupportedImageFormat =
|
|
6
|
+
| "png"
|
|
7
|
+
| "jpeg"
|
|
8
|
+
| "webp"
|
|
9
|
+
| "gif"
|
|
10
|
+
| "bmp"
|
|
11
|
+
| "tiff"
|
|
12
|
+
| "heic"
|
|
13
|
+
| "avif";
|
|
14
|
+
|
|
15
|
+
export interface ProcessImageOptions {
|
|
16
|
+
autoResize: boolean;
|
|
17
|
+
maxPixels: number;
|
|
18
|
+
maxDimension: number;
|
|
19
|
+
maxBase64Bytes: number;
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProcessedBrowserImage {
|
|
24
|
+
bytes: Buffer;
|
|
25
|
+
mimeType: string;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
originalWidth: number;
|
|
29
|
+
originalHeight: number;
|
|
30
|
+
sourceFormat: SupportedImageFormat;
|
|
31
|
+
outputFormat: "png" | "jpeg" | "webp" | "gif";
|
|
32
|
+
resized: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class UnsupportedImageError extends Error {}
|
|
36
|
+
export class ImageLimitError extends Error {}
|
|
37
|
+
|
|
38
|
+
interface InputDescriptor {
|
|
39
|
+
input: Buffer;
|
|
40
|
+
options: SharpOptions;
|
|
41
|
+
format: SupportedImageFormat;
|
|
42
|
+
animated: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Dimensions {
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
pages: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function detectImageFormat(bytes: Uint8Array): SupportedImageFormat | null {
|
|
52
|
+
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";
|
|
53
|
+
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "jpeg";
|
|
54
|
+
if (ascii(bytes, 0, 6) === "GIF87a" || ascii(bytes, 0, 6) === "GIF89a") return "gif";
|
|
55
|
+
if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") return "webp";
|
|
56
|
+
if (ascii(bytes, 0, 2) === "BM") return "bmp";
|
|
57
|
+
if (startsWith(bytes, [0x49, 0x49, 0x2a, 0x00]) || startsWith(bytes, [0x4d, 0x4d, 0x00, 0x2a])) {
|
|
58
|
+
return "tiff";
|
|
59
|
+
}
|
|
60
|
+
if (ascii(bytes, 4, 8) === "ftyp") {
|
|
61
|
+
const brands = ascii(bytes, 8, Math.min(bytes.length, 64));
|
|
62
|
+
if (brands.includes("avif") || brands.includes("avis")) return "avif";
|
|
63
|
+
if (
|
|
64
|
+
brands.includes("heic") ||
|
|
65
|
+
brands.includes("heix") ||
|
|
66
|
+
brands.includes("hevc") ||
|
|
67
|
+
brands.includes("hevx") ||
|
|
68
|
+
brands.startsWith("mif1") ||
|
|
69
|
+
brands.startsWith("msf1")
|
|
70
|
+
) {
|
|
71
|
+
return "heic";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function processImage(
|
|
78
|
+
source: Uint8Array,
|
|
79
|
+
options: ProcessImageOptions,
|
|
80
|
+
): Promise<ProcessedBrowserImage> {
|
|
81
|
+
throwIfAborted(options.signal);
|
|
82
|
+
const bytes = Buffer.from(source);
|
|
83
|
+
const format = detectImageFormat(bytes);
|
|
84
|
+
if (!format) throw new UnsupportedImageError("Unsupported image format");
|
|
85
|
+
const descriptor = await describeInput(bytes, format, options.maxPixels, options.signal);
|
|
86
|
+
const original = await dimensions(descriptor, options.maxPixels);
|
|
87
|
+
throwIfAborted(options.signal);
|
|
88
|
+
|
|
89
|
+
if (
|
|
90
|
+
!options.autoResize &&
|
|
91
|
+
(original.width > options.maxDimension || original.height > options.maxDimension)
|
|
92
|
+
) {
|
|
93
|
+
throw new ImageLimitError("Image dimensions exceed Pi's limit while auto-resize is disabled");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let target = options.autoResize ? fitWithin(original, options.maxDimension) : original;
|
|
97
|
+
let quality = 95;
|
|
98
|
+
while (true) {
|
|
99
|
+
throwIfAborted(options.signal);
|
|
100
|
+
const encoded = await encode(descriptor, target, quality);
|
|
101
|
+
throwIfAborted(options.signal);
|
|
102
|
+
const base64Bytes = Buffer.byteLength(encoded.data.toString("base64"));
|
|
103
|
+
if (base64Bytes <= options.maxBase64Bytes) {
|
|
104
|
+
const width = encoded.info.width;
|
|
105
|
+
const height = encoded.info.pageHeight ?? encoded.info.height;
|
|
106
|
+
const outputFormat = normalizeOutputFormat(encoded.info.format);
|
|
107
|
+
return {
|
|
108
|
+
bytes: encoded.data,
|
|
109
|
+
mimeType: mimeType(outputFormat),
|
|
110
|
+
width,
|
|
111
|
+
height,
|
|
112
|
+
originalWidth: original.width,
|
|
113
|
+
originalHeight: original.height,
|
|
114
|
+
sourceFormat: format,
|
|
115
|
+
outputFormat,
|
|
116
|
+
resized: width !== original.width || height !== original.height,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (!options.autoResize) {
|
|
120
|
+
throw new ImageLimitError("Processed image exceeds Pi's inline limit");
|
|
121
|
+
}
|
|
122
|
+
if ((format === "jpeg" || format === "webp") && quality > 40) {
|
|
123
|
+
quality = nextQuality(quality);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
quality = 95;
|
|
127
|
+
if (target.width === 1 && target.height === 1) {
|
|
128
|
+
throw new ImageLimitError("Image could not be reduced below Pi's inline limit");
|
|
129
|
+
}
|
|
130
|
+
target = {
|
|
131
|
+
...target,
|
|
132
|
+
width: target.width === 1 ? 1 : Math.max(1, Math.floor(target.width * 0.8)),
|
|
133
|
+
height: target.height === 1 ? 1 : Math.max(1, Math.floor(target.height * 0.8)),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function describeInput(
|
|
139
|
+
bytes: Buffer,
|
|
140
|
+
format: SupportedImageFormat,
|
|
141
|
+
maxPixels: number,
|
|
142
|
+
signal?: AbortSignal,
|
|
143
|
+
): Promise<InputDescriptor> {
|
|
144
|
+
if (format === "bmp") return describeBmp(bytes, maxPixels);
|
|
145
|
+
if (format !== "heic") {
|
|
146
|
+
return {
|
|
147
|
+
input: bytes,
|
|
148
|
+
options: {
|
|
149
|
+
animated: format === "gif",
|
|
150
|
+
failOn: "warning",
|
|
151
|
+
limitInputPixels: maxPixels,
|
|
152
|
+
sequentialRead: true,
|
|
153
|
+
},
|
|
154
|
+
format,
|
|
155
|
+
animated: format === "gif",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
throwIfAborted(signal);
|
|
160
|
+
let deferred: Awaited<ReturnType<typeof decodeHeic.all>> | undefined;
|
|
161
|
+
try {
|
|
162
|
+
deferred = await decodeHeic.all({ buffer: bytes });
|
|
163
|
+
const first = deferred[0];
|
|
164
|
+
if (!first) throw new UnsupportedImageError("HEIC image has no frames");
|
|
165
|
+
assertPixelLimit(first.width, first.height, maxPixels);
|
|
166
|
+
const decoded = await first.decode();
|
|
167
|
+
throwIfAborted(signal);
|
|
168
|
+
return {
|
|
169
|
+
input: Buffer.from(decoded.data),
|
|
170
|
+
options: {
|
|
171
|
+
raw: { width: decoded.width, height: decoded.height, channels: 4 },
|
|
172
|
+
limitInputPixels: maxPixels,
|
|
173
|
+
},
|
|
174
|
+
format,
|
|
175
|
+
animated: false,
|
|
176
|
+
};
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error instanceof ImageLimitError || isAbortError(error)) throw error;
|
|
179
|
+
throw new UnsupportedImageError(`HEIC decode failed: ${formatError(error)}`);
|
|
180
|
+
} finally {
|
|
181
|
+
deferred?.dispose();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function describeBmp(bytes: Buffer, maxPixels: number): InputDescriptor {
|
|
186
|
+
if (bytes.byteLength < 54 || bytes.readUInt32LE(14) < 40) {
|
|
187
|
+
throw new UnsupportedImageError("BMP header is unsupported");
|
|
188
|
+
}
|
|
189
|
+
const width = bytes.readInt32LE(18);
|
|
190
|
+
const height = Math.abs(bytes.readInt32LE(22));
|
|
191
|
+
assertPixelLimit(width, height, maxPixels);
|
|
192
|
+
let decoded: ReturnType<typeof decodeBmp>;
|
|
193
|
+
try {
|
|
194
|
+
decoded = decodeBmp(bytes, true);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
throw new UnsupportedImageError(`BMP decode failed: ${formatError(error)}`);
|
|
197
|
+
}
|
|
198
|
+
const rgba = Buffer.allocUnsafe(decoded.width * decoded.height * 4);
|
|
199
|
+
const bitDepth = bytes.readUInt16LE(28);
|
|
200
|
+
for (let offset = 0; offset < rgba.length; offset += 4) {
|
|
201
|
+
rgba[offset] = decoded.data[offset + 3] ?? 0;
|
|
202
|
+
rgba[offset + 1] = decoded.data[offset + 2] ?? 0;
|
|
203
|
+
rgba[offset + 2] = decoded.data[offset + 1] ?? 0;
|
|
204
|
+
rgba[offset + 3] = bitDepth === 32 ? (decoded.data[offset] ?? 255) : 255;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
input: rgba,
|
|
208
|
+
options: {
|
|
209
|
+
raw: { width: decoded.width, height: decoded.height, channels: 4 },
|
|
210
|
+
limitInputPixels: maxPixels,
|
|
211
|
+
},
|
|
212
|
+
format: "bmp",
|
|
213
|
+
animated: false,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function dimensions(descriptor: InputDescriptor, maxPixels: number): Promise<Dimensions> {
|
|
218
|
+
try {
|
|
219
|
+
const metadata = await sharp(descriptor.input, descriptor.options).metadata();
|
|
220
|
+
const width = metadata.autoOrient.width ?? metadata.width;
|
|
221
|
+
const totalHeight = metadata.autoOrient.height ?? metadata.height;
|
|
222
|
+
if (!width || !totalHeight) throw new Error("Image dimensions are missing");
|
|
223
|
+
const pages = metadata.pages ?? 1;
|
|
224
|
+
const height = metadata.pageHeight ?? Math.floor(totalHeight / pages);
|
|
225
|
+
assertPixelLimit(width, height * pages, maxPixels);
|
|
226
|
+
return { width, height, pages };
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (error instanceof ImageLimitError) throw error;
|
|
229
|
+
throw new Error(`Image decode failed: ${formatError(error)}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function encode(
|
|
234
|
+
descriptor: InputDescriptor,
|
|
235
|
+
target: Dimensions,
|
|
236
|
+
quality: number,
|
|
237
|
+
): Promise<{ data: Buffer; info: OutputInfo }> {
|
|
238
|
+
let pipeline: Sharp = sharp(descriptor.input, descriptor.options).autoOrient().keepIccProfile();
|
|
239
|
+
pipeline = pipeline.resize(target.width, target.height, {
|
|
240
|
+
fit: "inside",
|
|
241
|
+
withoutEnlargement: true,
|
|
242
|
+
kernel: sharp.kernel.lanczos3,
|
|
243
|
+
});
|
|
244
|
+
switch (descriptor.format) {
|
|
245
|
+
case "jpeg":
|
|
246
|
+
pipeline = pipeline.jpeg({ quality, chromaSubsampling: "4:4:4", mozjpeg: true });
|
|
247
|
+
break;
|
|
248
|
+
case "webp":
|
|
249
|
+
pipeline = pipeline.webp(
|
|
250
|
+
quality === 95 ? { lossless: true, effort: 5 } : { quality, alphaQuality: 100, effort: 5 },
|
|
251
|
+
);
|
|
252
|
+
break;
|
|
253
|
+
case "gif":
|
|
254
|
+
pipeline = pipeline.gif({ reuse: true, effort: 7 });
|
|
255
|
+
break;
|
|
256
|
+
case "png":
|
|
257
|
+
pipeline = pipeline.png({ compressionLevel: 9, adaptiveFiltering: true });
|
|
258
|
+
break;
|
|
259
|
+
default:
|
|
260
|
+
pipeline = pipeline.png({ compressionLevel: 9, adaptiveFiltering: true });
|
|
261
|
+
}
|
|
262
|
+
return pipeline.toBuffer({ resolveWithObject: true });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function fitWithin(dimensions: Dimensions, max: number): Dimensions {
|
|
266
|
+
const ratio = Math.min(1, max / dimensions.width, max / dimensions.height);
|
|
267
|
+
return {
|
|
268
|
+
...dimensions,
|
|
269
|
+
width: Math.max(1, Math.round(dimensions.width * ratio)),
|
|
270
|
+
height: Math.max(1, Math.round(dimensions.height * ratio)),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function nextQuality(quality: number): number {
|
|
275
|
+
if (quality > 85) return 85;
|
|
276
|
+
if (quality > 70) return 70;
|
|
277
|
+
if (quality > 55) return 55;
|
|
278
|
+
return 40;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function normalizeOutputFormat(format: string): "png" | "jpeg" | "webp" | "gif" {
|
|
282
|
+
if (format === "jpg") return "jpeg";
|
|
283
|
+
if (format === "png" || format === "jpeg" || format === "webp" || format === "gif") {
|
|
284
|
+
return format;
|
|
285
|
+
}
|
|
286
|
+
throw new UnsupportedImageError(`Unsupported output format: ${format}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function mimeType(format: "png" | "jpeg" | "webp" | "gif"): string {
|
|
290
|
+
return `image/${format}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function assertPixelLimit(width: number, height: number, max: number): void {
|
|
294
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
|
|
295
|
+
throw new ImageLimitError("Image dimensions are invalid");
|
|
296
|
+
}
|
|
297
|
+
if (width > Math.floor(max / height))
|
|
298
|
+
throw new ImageLimitError("Image pixel count exceeds the limit");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
302
|
+
if (signal?.aborted) throw abortError();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function abortError(): Error {
|
|
306
|
+
const error = new Error("Image processing aborted");
|
|
307
|
+
error.name = "AbortError";
|
|
308
|
+
return error;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isAbortError(error: unknown): boolean {
|
|
312
|
+
return error instanceof Error && error.name === "AbortError";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function startsWith(bytes: Uint8Array, signature: readonly number[]): boolean {
|
|
316
|
+
return signature.every((byte, index) => bytes[index] === byte);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function ascii(bytes: Uint8Array, start: number, end: number): string {
|
|
320
|
+
return Buffer.from(bytes.subarray(start, end)).toString("latin1");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function formatError(error: unknown): string {
|
|
324
|
+
return error instanceof Error ? error.message : String(error);
|
|
325
|
+
}
|