pi-paster 0.1.4 → 0.2.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.
@@ -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
@@ -4,9 +4,20 @@ import {
4
4
  type Component,
5
5
  type ImageTheme,
6
6
  truncateToWidth,
7
+ visibleWidth,
7
8
  } from "@earendil-works/pi-tui";
8
9
  import type { ImageAttachment } from "./types.ts";
9
10
 
11
+ function formatAttachmentLine(
12
+ attachment: ImageAttachment,
13
+ width: number,
14
+ style: (text: string) => string,
15
+ ): string {
16
+ const maxWidth = Math.max(1, width);
17
+ const line = style(`Attached ${attachment.placeholder} ${attachment.originalPath}`);
18
+ return visibleWidth(line) > maxWidth ? truncateToWidth(line, maxWidth, "") : line;
19
+ }
20
+
10
21
  export class ImagePreviewMessage implements Component {
11
22
  private readonly images: Image[];
12
23
 
@@ -26,12 +37,11 @@ export class ImagePreviewMessage implements Component {
26
37
 
27
38
  render(width: number): string[] {
28
39
  const lines: string[] = [];
40
+ const safeWidth = Math.max(1, width);
29
41
  for (let index = 0; index < this.attachments.length; index++) {
30
42
  const attachment = this.attachments[index]!;
31
- lines.push(
32
- this.theme.fallbackColor(`Attached ${attachment.placeholder} ${attachment.originalPath}`),
33
- );
34
- lines.push(...this.images[index]!.render(width));
43
+ lines.push(formatAttachmentLine(attachment, safeWidth, this.theme.fallbackColor));
44
+ lines.push(...this.images[index]!.render(safeWidth));
35
45
  }
36
46
  return lines;
37
47
  }
@@ -68,8 +78,7 @@ export class CursorImagePreviewWidget implements Component {
68
78
  }
69
79
 
70
80
  private headerLine(width: number): string {
71
- const title = `Attached ${this.attachment.placeholder} ${this.attachment.originalPath}`;
72
- return this.theme.title(truncateToWidth(title, Math.max(1, width), ""));
81
+ return formatAttachmentLine(this.attachment, width, this.theme.title);
73
82
  }
74
83
 
75
84
  private createImage(attachment: ImageAttachment, maxWidthCells = 60): Image {
@@ -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
- export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
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 {