pi-paster 0.1.5 → 0.2.1
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 +10 -6
- package/dist/index.d.mts +75 -3
- package/dist/index.mjs +546 -48
- package/package.json +5 -2
- package/src/clipboard.ts +73 -3
- package/src/config.ts +14 -0
- package/src/editor.ts +107 -19
- package/src/image-utils.ts +200 -11
- package/src/index.ts +27 -10
- package/src/optimize-image.ts +209 -0
- package/src/preview.ts +54 -5
- package/src/terminal-input.ts +2 -6
- package/src/types.ts +19 -1
package/src/index.ts
CHANGED
|
@@ -2,13 +2,14 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { readClipboardImage } from "./clipboard.ts";
|
|
3
3
|
import { type PasterConfig, resolvePasterConfig } from "./config.ts";
|
|
4
4
|
import { PasterEditor } from "./editor.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { appendImagePathContext, imagesForTextOptimized } from "./image-utils.ts";
|
|
6
6
|
import { CursorImagePreviewWidget, ImagePreviewMessage } from "./preview.ts";
|
|
7
7
|
import { AttachmentStore } from "./store.ts";
|
|
8
8
|
import { createImagePasteTerminalInputHandler } from "./terminal-input.ts";
|
|
9
9
|
import type { ImageAttachment, PasterPreviewDetails } from "./types.ts";
|
|
10
10
|
|
|
11
11
|
export * from "./clipboard.ts";
|
|
12
|
+
export * from "./optimize-image.ts";
|
|
12
13
|
export * from "./config.ts";
|
|
13
14
|
export * from "./editor.ts";
|
|
14
15
|
export * from "./image-utils.ts";
|
|
@@ -28,15 +29,22 @@ export default function paster(pi: ExtensionAPI, config: PasterConfig = {}): voi
|
|
|
28
29
|
let activeEditor: PasterEditor | undefined;
|
|
29
30
|
let unsubscribeTerminalInput: (() => void) | undefined;
|
|
30
31
|
|
|
31
|
-
pi.registerMessageRenderer<PasterPreviewDetails>("paster-preview", (message,
|
|
32
|
+
pi.registerMessageRenderer<PasterPreviewDetails>("paster-preview", (message, options, theme) => {
|
|
32
33
|
const placeholders = message.details?.placeholders ?? [];
|
|
33
34
|
const attachments = store
|
|
34
35
|
.list()
|
|
35
36
|
.filter((attachment) => placeholders.includes(attachment.placeholder));
|
|
36
37
|
if (attachments.length === 0) return undefined;
|
|
37
|
-
return new ImagePreviewMessage(
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
return new ImagePreviewMessage(
|
|
39
|
+
attachments,
|
|
40
|
+
{
|
|
41
|
+
fallbackColor: (text) => theme.fg("muted", text),
|
|
42
|
+
background: (text) => theme.bg("toolSuccessBg", text),
|
|
43
|
+
title: (text) => theme.fg("toolTitle", theme.bold(text)),
|
|
44
|
+
muted: (text) => theme.fg("muted", text),
|
|
45
|
+
},
|
|
46
|
+
{ expanded: options.expanded, style: resolvedConfig.submittedPreviewStyle },
|
|
47
|
+
);
|
|
40
48
|
});
|
|
41
49
|
|
|
42
50
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -111,15 +119,16 @@ export default function paster(pi: ExtensionAPI, config: PasterConfig = {}): voi
|
|
|
111
119
|
});
|
|
112
120
|
|
|
113
121
|
function previewMessage(attachments: ImageAttachment[]) {
|
|
122
|
+
const placeholders = attachments.map((attachment) => attachment.placeholder);
|
|
114
123
|
return {
|
|
115
124
|
customType: "paster-preview",
|
|
116
|
-
content: ""
|
|
125
|
+
content: `(attachment preview: ${placeholders.join(", ")})`,
|
|
117
126
|
display: true,
|
|
118
|
-
details: { placeholders
|
|
127
|
+
details: { placeholders },
|
|
119
128
|
};
|
|
120
129
|
}
|
|
121
130
|
|
|
122
|
-
pi.on("input", (event, ctx) => {
|
|
131
|
+
pi.on("input", async (event, ctx) => {
|
|
123
132
|
if (event.source === "extension") return { action: "continue" as const };
|
|
124
133
|
if (ctx.hasUI) {
|
|
125
134
|
activeEditor?.clearCursorPreview();
|
|
@@ -136,10 +145,18 @@ export default function paster(pi: ExtensionAPI, config: PasterConfig = {}): voi
|
|
|
136
145
|
pi.sendMessage(previewMessage(attachments), { deliverAs: "followUp" });
|
|
137
146
|
}
|
|
138
147
|
|
|
148
|
+
// Optimize images on-submit so we never exceed Anthropic's 5 MB/image or
|
|
149
|
+
// 32 MB/request caps. Per-attachment caching means each image is only
|
|
150
|
+
// resized/recompressed once across the whole session.
|
|
151
|
+
const images = await imagesForTextOptimized(store, event.text, event.images);
|
|
152
|
+
const text = resolvedConfig.includeImagePathsInPrompt
|
|
153
|
+
? appendImagePathContext(event.text, attachments)
|
|
154
|
+
: event.text;
|
|
155
|
+
|
|
139
156
|
return {
|
|
140
157
|
action: "transform" as const,
|
|
141
|
-
text
|
|
142
|
-
images
|
|
158
|
+
text,
|
|
159
|
+
images,
|
|
143
160
|
};
|
|
144
161
|
});
|
|
145
162
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image optimizer for pi-paster.
|
|
3
|
+
*
|
|
4
|
+
* Anthropic Messages API hard limits:
|
|
5
|
+
* - 5 MB per image (base64-decoded)
|
|
6
|
+
* - 8000 px on any dimension
|
|
7
|
+
* - 32 MB per request total
|
|
8
|
+
*
|
|
9
|
+
* Claude internally downsamples every input image so the long edge is
|
|
10
|
+
* - 1568 px for most models
|
|
11
|
+
* - 2576 px for Opus 4.7
|
|
12
|
+
* which means: any client-side downscale at or above ~2600 px is lossless
|
|
13
|
+
* from the model's point of view. We resize (aspect-preserving) instead of
|
|
14
|
+
* cropping so no visual information is thrown away.
|
|
15
|
+
*
|
|
16
|
+
* Strategy applied in order:
|
|
17
|
+
* 1. If width or height > 8000 px → resize so the long edge is 8000 px.
|
|
18
|
+
* 2. If decoded bytes > 5 MB → re-encode as JPEG q=95, then 90/85/80/70/60.
|
|
19
|
+
* 3. If still > 5 MB → progressively shrink the long edge to 6000 → 4000
|
|
20
|
+
* → 3000 → 2000 px, re-running JPEG quality ladder each step.
|
|
21
|
+
*
|
|
22
|
+
* `sharp` is loaded lazily so the extension can still ship if the native
|
|
23
|
+
* binding is unavailable (we just skip optimization with a log line).
|
|
24
|
+
*/
|
|
25
|
+
import type { SupportedImageMimeType } from "./types.ts";
|
|
26
|
+
import { ANTHROPIC_MAX_DIMENSION, ANTHROPIC_MAX_IMAGE_BYTES } from "./types.ts";
|
|
27
|
+
|
|
28
|
+
type Sharp = (input?: Buffer) => SharpInstance;
|
|
29
|
+
interface SharpInstance {
|
|
30
|
+
metadata(): Promise<{ width?: number; height?: number; format?: string }>;
|
|
31
|
+
resize(opts: {
|
|
32
|
+
width?: number;
|
|
33
|
+
height?: number;
|
|
34
|
+
fit?: string;
|
|
35
|
+
withoutEnlargement?: boolean;
|
|
36
|
+
}): SharpInstance;
|
|
37
|
+
jpeg(opts: { quality: number; mozjpeg?: boolean }): SharpInstance;
|
|
38
|
+
png(opts?: { quality?: number; compressionLevel?: number }): SharpInstance;
|
|
39
|
+
toBuffer(): Promise<Buffer>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let _sharp: Sharp | null | undefined;
|
|
43
|
+
async function getSharp(): Promise<Sharp | null> {
|
|
44
|
+
if (_sharp !== undefined) return _sharp;
|
|
45
|
+
try {
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
47
|
+
const mod: any = await import("sharp");
|
|
48
|
+
const fn = (typeof mod === "function" ? mod : mod?.default) as Sharp | undefined;
|
|
49
|
+
_sharp = typeof fn === "function" ? fn : null;
|
|
50
|
+
} catch {
|
|
51
|
+
_sharp = null;
|
|
52
|
+
}
|
|
53
|
+
return _sharp;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface OptimizeResult {
|
|
57
|
+
data: string; // base64
|
|
58
|
+
mimeType: SupportedImageMimeType;
|
|
59
|
+
originalBytes: number;
|
|
60
|
+
finalBytes: number;
|
|
61
|
+
originalDim?: { width: number; height: number };
|
|
62
|
+
finalDim?: { width: number; height: number };
|
|
63
|
+
actions: string[];
|
|
64
|
+
changed: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SHRINK_LADDER = [6000, 4000, 3000, 2000];
|
|
68
|
+
const JPEG_QUALITY_LADDER = [95, 90, 85, 80, 70, 60];
|
|
69
|
+
|
|
70
|
+
export async function optimizeImageBytes(
|
|
71
|
+
input: Buffer,
|
|
72
|
+
mime: SupportedImageMimeType,
|
|
73
|
+
): Promise<OptimizeResult> {
|
|
74
|
+
const originalBytes = input.length;
|
|
75
|
+
const noop = (): OptimizeResult => ({
|
|
76
|
+
data: input.toString("base64"),
|
|
77
|
+
mimeType: mime,
|
|
78
|
+
originalBytes,
|
|
79
|
+
finalBytes: originalBytes,
|
|
80
|
+
actions: [],
|
|
81
|
+
changed: false,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Fast path: already under both limits.
|
|
85
|
+
if (originalBytes <= ANTHROPIC_MAX_IMAGE_BYTES) {
|
|
86
|
+
// Still need dimension check, but cheap to skip sharp if image is small.
|
|
87
|
+
if (originalBytes <= 256 * 1024) return noop();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sharp = await getSharp();
|
|
91
|
+
if (!sharp) return noop(); // sharp missing — fall back silently.
|
|
92
|
+
|
|
93
|
+
const meta = await sharp(input).metadata();
|
|
94
|
+
const origW = meta.width ?? 0;
|
|
95
|
+
const origH = meta.height ?? 0;
|
|
96
|
+
if (!origW || !origH) return noop();
|
|
97
|
+
|
|
98
|
+
const actions: string[] = [];
|
|
99
|
+
const originalDim = { width: origW, height: origH };
|
|
100
|
+
|
|
101
|
+
// Step 1: respect 8000 px dimension cap (resize, do not crop).
|
|
102
|
+
let workBuf = input;
|
|
103
|
+
let workW = origW;
|
|
104
|
+
let workH = origH;
|
|
105
|
+
if (workW > ANTHROPIC_MAX_DIMENSION || workH > ANTHROPIC_MAX_DIMENSION) {
|
|
106
|
+
workBuf = await sharp(workBuf)
|
|
107
|
+
.resize({
|
|
108
|
+
width: workW >= workH ? ANTHROPIC_MAX_DIMENSION : undefined,
|
|
109
|
+
height: workH > workW ? ANTHROPIC_MAX_DIMENSION : undefined,
|
|
110
|
+
fit: "inside",
|
|
111
|
+
withoutEnlargement: true,
|
|
112
|
+
})
|
|
113
|
+
.toBuffer();
|
|
114
|
+
const m = await sharp(workBuf).metadata();
|
|
115
|
+
workW = m.width ?? workW;
|
|
116
|
+
workH = m.height ?? workH;
|
|
117
|
+
actions.push(`resize to ${workW}x${workH} (8000px cap)`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Step 2: bytes within limit? done.
|
|
121
|
+
if (workBuf.length <= ANTHROPIC_MAX_IMAGE_BYTES && actions.length === 0) return noop();
|
|
122
|
+
if (workBuf.length <= ANTHROPIC_MAX_IMAGE_BYTES) {
|
|
123
|
+
return {
|
|
124
|
+
data: workBuf.toString("base64"),
|
|
125
|
+
mimeType: mime,
|
|
126
|
+
originalBytes,
|
|
127
|
+
finalBytes: workBuf.length,
|
|
128
|
+
originalDim,
|
|
129
|
+
finalDim: { width: workW, height: workH },
|
|
130
|
+
actions,
|
|
131
|
+
changed: true,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Step 3: JPEG quality ladder.
|
|
136
|
+
let outMime: SupportedImageMimeType = "image/jpeg";
|
|
137
|
+
let attempt = workBuf;
|
|
138
|
+
for (const q of JPEG_QUALITY_LADDER) {
|
|
139
|
+
attempt = await sharp(workBuf).jpeg({ quality: q, mozjpeg: true }).toBuffer();
|
|
140
|
+
if (attempt.length <= ANTHROPIC_MAX_IMAGE_BYTES) {
|
|
141
|
+
actions.push(`jpeg q=${q} → ${formatBytes(attempt.length)}`);
|
|
142
|
+
return {
|
|
143
|
+
data: attempt.toString("base64"),
|
|
144
|
+
mimeType: outMime,
|
|
145
|
+
originalBytes,
|
|
146
|
+
finalBytes: attempt.length,
|
|
147
|
+
originalDim,
|
|
148
|
+
finalDim: { width: workW, height: workH },
|
|
149
|
+
actions,
|
|
150
|
+
changed: true,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
actions.push(`jpeg q=60 still ${formatBytes(attempt.length)} — shrinking`);
|
|
155
|
+
|
|
156
|
+
// Step 4: shrink the long edge and retry quality ladder each step.
|
|
157
|
+
for (const longEdge of SHRINK_LADDER) {
|
|
158
|
+
if (Math.max(workW, workH) <= longEdge) continue;
|
|
159
|
+
const resized = await sharp(workBuf)
|
|
160
|
+
.resize({
|
|
161
|
+
width: workW >= workH ? longEdge : undefined,
|
|
162
|
+
height: workH > workW ? longEdge : undefined,
|
|
163
|
+
fit: "inside",
|
|
164
|
+
withoutEnlargement: true,
|
|
165
|
+
})
|
|
166
|
+
.toBuffer();
|
|
167
|
+
const m = await sharp(resized).metadata();
|
|
168
|
+
const newW = m.width ?? workW;
|
|
169
|
+
const newH = m.height ?? workH;
|
|
170
|
+
for (const q of JPEG_QUALITY_LADDER) {
|
|
171
|
+
attempt = await sharp(resized).jpeg({ quality: q, mozjpeg: true }).toBuffer();
|
|
172
|
+
if (attempt.length <= ANTHROPIC_MAX_IMAGE_BYTES) {
|
|
173
|
+
actions.push(`resize ${newW}x${newH} + jpeg q=${q} → ${formatBytes(attempt.length)}`);
|
|
174
|
+
return {
|
|
175
|
+
data: attempt.toString("base64"),
|
|
176
|
+
mimeType: outMime,
|
|
177
|
+
originalBytes,
|
|
178
|
+
finalBytes: attempt.length,
|
|
179
|
+
originalDim,
|
|
180
|
+
finalDim: { width: newW, height: newH },
|
|
181
|
+
actions,
|
|
182
|
+
changed: true,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
workBuf = resized;
|
|
187
|
+
workW = newW;
|
|
188
|
+
workH = newH;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Give up — return last attempt anyway; pi will at least try.
|
|
192
|
+
actions.push(`final ${formatBytes(attempt.length)} — over limit`);
|
|
193
|
+
return {
|
|
194
|
+
data: attempt.toString("base64"),
|
|
195
|
+
mimeType: outMime,
|
|
196
|
+
originalBytes,
|
|
197
|
+
finalBytes: attempt.length,
|
|
198
|
+
originalDim,
|
|
199
|
+
finalDim: { width: workW, height: workH },
|
|
200
|
+
actions,
|
|
201
|
+
changed: true,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function formatBytes(bytes: number): string {
|
|
206
|
+
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
|
|
207
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
|
208
|
+
return `${bytes}B`;
|
|
209
|
+
}
|
package/src/preview.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
+
Box,
|
|
3
|
+
Container,
|
|
2
4
|
getCellDimensions,
|
|
3
5
|
Image,
|
|
4
6
|
type Component,
|
|
5
7
|
type ImageTheme,
|
|
8
|
+
Spacer,
|
|
9
|
+
Text,
|
|
6
10
|
truncateToWidth,
|
|
7
11
|
visibleWidth,
|
|
8
12
|
} from "@earendil-works/pi-tui";
|
|
@@ -18,12 +22,21 @@ function formatAttachmentLine(
|
|
|
18
22
|
return visibleWidth(line) > maxWidth ? truncateToWidth(line, maxWidth, "") : line;
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
export type ImagePreviewMessageStyle = "raw" | "collapsible";
|
|
26
|
+
|
|
27
|
+
interface ImagePreviewMessageTheme extends ImageTheme {
|
|
28
|
+
background?: (text: string) => string;
|
|
29
|
+
title?: (text: string) => string;
|
|
30
|
+
muted?: (text: string) => string;
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
export class ImagePreviewMessage implements Component {
|
|
22
34
|
private readonly images: Image[];
|
|
23
35
|
|
|
24
36
|
constructor(
|
|
25
37
|
private readonly attachments: ImageAttachment[],
|
|
26
|
-
private readonly theme:
|
|
38
|
+
private readonly theme: ImagePreviewMessageTheme,
|
|
39
|
+
private readonly options: { expanded?: boolean; style?: ImagePreviewMessageStyle } = {},
|
|
27
40
|
) {
|
|
28
41
|
this.images = attachments.map(
|
|
29
42
|
(attachment) =>
|
|
@@ -35,18 +48,54 @@ export class ImagePreviewMessage implements Component {
|
|
|
35
48
|
);
|
|
36
49
|
}
|
|
37
50
|
|
|
51
|
+
invalidate(): void {
|
|
52
|
+
for (const image of this.images) image.invalidate();
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
render(width: number): string[] {
|
|
56
|
+
return this.options.style === "collapsible"
|
|
57
|
+
? this.renderCollapsible(width)
|
|
58
|
+
: this.renderRaw(width);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private renderRaw(width: number): string[] {
|
|
39
62
|
const lines: string[] = [];
|
|
63
|
+
const safeWidth = Math.max(1, width);
|
|
40
64
|
for (let index = 0; index < this.attachments.length; index++) {
|
|
41
65
|
const attachment = this.attachments[index]!;
|
|
42
|
-
lines.push(formatAttachmentLine(attachment,
|
|
43
|
-
lines.push(...this.images[index]!.render(
|
|
66
|
+
lines.push(formatAttachmentLine(attachment, safeWidth, this.theme.fallbackColor));
|
|
67
|
+
lines.push(...this.images[index]!.render(safeWidth));
|
|
44
68
|
}
|
|
45
69
|
return lines;
|
|
46
70
|
}
|
|
47
71
|
|
|
48
|
-
|
|
49
|
-
|
|
72
|
+
private renderCollapsible(width: number): string[] {
|
|
73
|
+
const container = new Container();
|
|
74
|
+
container.addChild(new Spacer(1));
|
|
75
|
+
const box = new Box(1, 1, this.theme.background);
|
|
76
|
+
container.addChild(box);
|
|
77
|
+
|
|
78
|
+
const title = this.theme.title ?? this.theme.fallbackColor;
|
|
79
|
+
const muted = this.theme.muted ?? this.theme.fallbackColor;
|
|
80
|
+
const summary =
|
|
81
|
+
this.attachments.length === 1
|
|
82
|
+
? `Attached ${this.attachments[0]!.placeholder}`
|
|
83
|
+
: `Attached ${this.attachments.length} images`;
|
|
84
|
+
const suffix = this.options.expanded ? " (ctrl+o to collapse)" : " (ctrl+o to expand)";
|
|
85
|
+
box.addChild(new Text(`${title(summary)}${muted(suffix)}`, 0, 0));
|
|
86
|
+
|
|
87
|
+
for (const attachment of this.attachments) {
|
|
88
|
+
box.addChild(new Text(formatAttachmentLine(attachment, width, muted), 0, 0));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const lines = container.render(width);
|
|
92
|
+
if (!this.options.expanded) return lines;
|
|
93
|
+
|
|
94
|
+
const safeWidth = Math.max(1, width);
|
|
95
|
+
for (let index = 0; index < this.attachments.length; index++) {
|
|
96
|
+
lines.push(...this.images[index]!.render(safeWidth));
|
|
97
|
+
}
|
|
98
|
+
return lines;
|
|
50
99
|
}
|
|
51
100
|
}
|
|
52
101
|
|
package/src/terminal-input.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PASTE_END, PASTE_START } from "./editor.ts";
|
|
2
|
-
import { replaceImagePathsInText } from "./image-utils.ts";
|
|
2
|
+
import { describeReject, replaceImagePathsInText } from "./image-utils.ts";
|
|
3
3
|
import type { AttachmentStore } from "./store.ts";
|
|
4
4
|
import type { ImageAttachment, LoadImageResult } from "./types.ts";
|
|
5
5
|
|
|
@@ -19,11 +19,7 @@ export function createImagePasteTerminalInputHandler(options: {
|
|
|
19
19
|
cwd: options.cwd,
|
|
20
20
|
store: options.store,
|
|
21
21
|
loadImage: options.loadImage,
|
|
22
|
-
onReject: (result) =>
|
|
23
|
-
if (result.reason === "too-large") {
|
|
24
|
-
options.notify?.(`paster: image is over 10 MB and was not attached: ${result.path}`);
|
|
25
|
-
}
|
|
26
|
-
},
|
|
22
|
+
onReject: (result) => describeReject(result, options.notify),
|
|
27
23
|
});
|
|
28
24
|
|
|
29
25
|
return (data: string): TerminalInputResult => {
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import type { ImageDimensions } from "@earendil-works/pi-tui";
|
|
2
2
|
|
|
3
3
|
export const EXTENSION_NAME = "paster";
|
|
4
|
-
|
|
4
|
+
// Cap on the source file we read from disk. Larger inputs are rejected up
|
|
5
|
+
// front with `too-large` so we never try to base64-encode a multi-gigabyte
|
|
6
|
+
// file. Raised from the legacy 10 MB so high-resolution screenshots and raw
|
|
7
|
+
// camera shots can be ingested and then shrunk by optimize-image.ts before
|
|
8
|
+
// being attached.
|
|
9
|
+
export const MAX_IMAGE_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
// Hard limits enforced by the Anthropic Messages API. Used by
|
|
12
|
+
// optimize-image.ts to decide when an attachment needs to be shrunk.
|
|
13
|
+
export const ANTHROPIC_MAX_DIMENSION = 8000;
|
|
14
|
+
export const ANTHROPIC_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
5
15
|
|
|
6
16
|
export type SupportedImageMimeType = "image/png" | "image/jpeg" | "image/webp" | "image/gif";
|
|
7
17
|
|
|
@@ -13,6 +23,14 @@ export interface ImageAttachment {
|
|
|
13
23
|
data: string;
|
|
14
24
|
dimensions?: ImageDimensions;
|
|
15
25
|
createdAt: number;
|
|
26
|
+
/** True once optimizeImageBytes has run on this attachment. */
|
|
27
|
+
optimized?: boolean;
|
|
28
|
+
/** Original (pre-optimization) base64 size in bytes — informational. */
|
|
29
|
+
originalBytes?: number;
|
|
30
|
+
/** Final (post-optimization) base64 size in bytes — informational. */
|
|
31
|
+
finalBytes?: number;
|
|
32
|
+
/** Human-readable trail of optimization actions applied, if any. */
|
|
33
|
+
optimizeActions?: string[];
|
|
16
34
|
}
|
|
17
35
|
|
|
18
36
|
export interface LoadedImage {
|