pi-paster 0.1.5 → 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.
- package/dist/index.d.mts +49 -1
- package/dist/index.mjs +416 -27
- package/package.json +5 -2
- package/src/clipboard.ts +73 -3
- package/src/editor.ts +2 -8
- package/src/image-utils.ts +192 -11
- package/src/index.ts +12 -5
- package/src/optimize-image.ts +209 -0
- package/src/preview.ts +3 -2
- package/src/terminal-input.ts +2 -6
- package/src/types.ts +19 -1
package/src/clipboard.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { detectImageMimeType, dimensionsForImage } from "./image-utils.ts";
|
|
@@ -14,8 +14,78 @@ export type ClipboardImageResult =
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
export function readClipboardImage(maxBytes = MAX_IMAGE_BYTES): ClipboardImageResult {
|
|
17
|
-
if (process.platform
|
|
18
|
-
return
|
|
17
|
+
if (process.platform === "darwin") return readMacOSClipboardImage(maxBytes);
|
|
18
|
+
if (process.platform === "win32") return readWindowsClipboardImage(maxBytes);
|
|
19
|
+
if (process.platform === "linux" && isWSL()) return readWindowsClipboardImage(maxBytes);
|
|
20
|
+
return { ok: false, reason: "unsupported-platform" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isWSL(): boolean {
|
|
24
|
+
try {
|
|
25
|
+
return /microsoft|wsl/i.test(readFileSync("/proc/version", "utf8"));
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolvePowerShell(): string | null {
|
|
32
|
+
if (process.platform === "win32") return "powershell.exe";
|
|
33
|
+
const candidates = [
|
|
34
|
+
"/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
|
|
35
|
+
"/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/powershell.exe",
|
|
36
|
+
];
|
|
37
|
+
for (const c of candidates) {
|
|
38
|
+
if (existsSync(c)) return c;
|
|
39
|
+
}
|
|
40
|
+
return "powershell.exe";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readWindowsClipboardImage(maxBytes: number): ClipboardImageResult {
|
|
44
|
+
const exe = resolvePowerShell();
|
|
45
|
+
if (!exe) return { ok: false, reason: "unsupported-platform" };
|
|
46
|
+
|
|
47
|
+
const script = [
|
|
48
|
+
"$ErrorActionPreference = 'Stop'",
|
|
49
|
+
"Add-Type -AssemblyName System.Windows.Forms | Out-Null",
|
|
50
|
+
"Add-Type -AssemblyName System.Drawing | Out-Null",
|
|
51
|
+
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
|
|
52
|
+
"if ($img -eq $null) { exit 2 }",
|
|
53
|
+
"$ms = New-Object System.IO.MemoryStream",
|
|
54
|
+
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)",
|
|
55
|
+
"[Console]::Out.Write([Convert]::ToBase64String($ms.ToArray()))",
|
|
56
|
+
].join("; ");
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const result = spawnSync(exe, ["-NoProfile", "-NonInteractive", "-STA", "-Command", script], {
|
|
60
|
+
timeout: 5000,
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
63
|
+
});
|
|
64
|
+
if (result.status === 2) return { ok: false, reason: "empty" };
|
|
65
|
+
if (result.status !== 0) return { ok: false, reason: "read-error" };
|
|
66
|
+
|
|
67
|
+
const data = (result.stdout || "").trim();
|
|
68
|
+
if (!data) return { ok: false, reason: "empty" };
|
|
69
|
+
|
|
70
|
+
const bytes = Buffer.from(data, "base64");
|
|
71
|
+
if (bytes.length === 0) return { ok: false, reason: "empty" };
|
|
72
|
+
if (bytes.length > maxBytes) return { ok: false, reason: "too-large" };
|
|
73
|
+
|
|
74
|
+
const mimeType = detectImageMimeType(bytes);
|
|
75
|
+
if (!mimeType) return { ok: false, reason: "unsupported" };
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
ok: true,
|
|
79
|
+
image: {
|
|
80
|
+
originalPath: "clipboard.png",
|
|
81
|
+
mimeType,
|
|
82
|
+
data,
|
|
83
|
+
dimensions: dimensionsForImage(data, mimeType),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
} catch {
|
|
87
|
+
return { ok: false, reason: "read-error" };
|
|
88
|
+
}
|
|
19
89
|
}
|
|
20
90
|
|
|
21
91
|
function readMacOSClipboardImage(maxBytes: number): ClipboardImageResult {
|
package/src/editor.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
3
|
-
import { replaceImagePathsInText } from "./image-utils.ts";
|
|
3
|
+
import { describeReject, replaceImagePathsInText } from "./image-utils.ts";
|
|
4
4
|
import type { AttachmentStore } from "./store.ts";
|
|
5
5
|
import type { ImageAttachment } from "./types.ts";
|
|
6
6
|
|
|
@@ -189,13 +189,7 @@ export class PasterEditor extends CustomEditor {
|
|
|
189
189
|
return replaceImagePathsInText(text, {
|
|
190
190
|
cwd: this.pasterOptions.cwd,
|
|
191
191
|
store: this.pasterOptions.store,
|
|
192
|
-
onReject: (result) =>
|
|
193
|
-
if (result.reason === "too-large") {
|
|
194
|
-
this.pasterOptions.notify(
|
|
195
|
-
`paster: image is over 10 MB and was not attached: ${result.path}`,
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
},
|
|
192
|
+
onReject: (result) => describeReject(result, this.pasterOptions.notify),
|
|
199
193
|
});
|
|
200
194
|
}
|
|
201
195
|
|
package/src/image-utils.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { platform } from "node:process";
|
|
4
5
|
import { getImageDimensions } from "@earendil-works/pi-tui";
|
|
5
6
|
import type { AttachmentStore } from "./store.ts";
|
|
6
7
|
import {
|
|
@@ -10,14 +11,18 @@ import {
|
|
|
10
11
|
type PasterImageContent,
|
|
11
12
|
type SupportedImageMimeType,
|
|
12
13
|
} from "./types.ts";
|
|
14
|
+
import { optimizeImageBytes } from "./optimize-image.ts";
|
|
13
15
|
|
|
14
16
|
interface PathToken {
|
|
15
17
|
raw: string;
|
|
16
18
|
value: string;
|
|
17
19
|
start: number;
|
|
18
20
|
end: number;
|
|
21
|
+
bare: boolean;
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
const MAX_BARE_PATH_EXTENSIONS = 8;
|
|
25
|
+
|
|
21
26
|
export function detectImageMimeType(bytes: Uint8Array): SupportedImageMimeType | undefined {
|
|
22
27
|
if (
|
|
23
28
|
bytes.length >= 8 &&
|
|
@@ -62,9 +67,62 @@ export function detectImageMimeType(bytes: Uint8Array): SupportedImageMimeType |
|
|
|
62
67
|
return undefined;
|
|
63
68
|
}
|
|
64
69
|
|
|
70
|
+
const WINDOWS_DRIVE_PATH = /^([a-zA-Z]):[\\/](.*)$/;
|
|
71
|
+
|
|
72
|
+
export function isWindowsDrivePath(value: string): boolean {
|
|
73
|
+
return WINDOWS_DRIVE_PATH.test(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isWindowsUncPath(value: string): boolean {
|
|
77
|
+
return value.startsWith("\\\\") && value.length > 2;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isWindowsLikePath(value: string): boolean {
|
|
81
|
+
return isWindowsDrivePath(value) || isWindowsUncPath(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let cachedIsWsl: boolean | undefined;
|
|
85
|
+
export function isWsl(): boolean {
|
|
86
|
+
if (cachedIsWsl !== undefined) return cachedIsWsl;
|
|
87
|
+
if (platform !== "linux") {
|
|
88
|
+
cachedIsWsl = false;
|
|
89
|
+
return cachedIsWsl;
|
|
90
|
+
}
|
|
91
|
+
if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) {
|
|
92
|
+
cachedIsWsl = true;
|
|
93
|
+
return cachedIsWsl;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const release = readFileSync("/proc/version", "utf8");
|
|
97
|
+
cachedIsWsl = /microsoft|wsl/i.test(release);
|
|
98
|
+
} catch {
|
|
99
|
+
cachedIsWsl = false;
|
|
100
|
+
}
|
|
101
|
+
return cachedIsWsl;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function windowsToWslPath(windowsPath: string): string {
|
|
105
|
+
const driveMatch = WINDOWS_DRIVE_PATH.exec(windowsPath);
|
|
106
|
+
if (driveMatch) {
|
|
107
|
+
const drive = driveMatch[1]!.toLowerCase();
|
|
108
|
+
const rest = driveMatch[2]!.replace(/\\/g, "/");
|
|
109
|
+
return rest.length > 0 ? `/mnt/${drive}/${rest}` : `/mnt/${drive}`;
|
|
110
|
+
}
|
|
111
|
+
if (isWindowsUncPath(windowsPath)) {
|
|
112
|
+
// \\server\share\path -> //wsl.localhost-style not generally translatable; pass through.
|
|
113
|
+
return windowsPath.replace(/\\/g, "/");
|
|
114
|
+
}
|
|
115
|
+
return windowsPath;
|
|
116
|
+
}
|
|
117
|
+
|
|
65
118
|
export function resolveImagePath(input: string, cwd: string): string {
|
|
66
119
|
if (input === "~") return homedir();
|
|
67
120
|
if (input.startsWith("~/")) return resolve(homedir(), input.slice(2));
|
|
121
|
+
if (isWindowsLikePath(input)) {
|
|
122
|
+
if (platform === "win32") return input;
|
|
123
|
+
if (isWsl()) return windowsToWslPath(input);
|
|
124
|
+
return input;
|
|
125
|
+
}
|
|
68
126
|
if (isAbsolute(input)) return input;
|
|
69
127
|
return resolve(cwd, input);
|
|
70
128
|
}
|
|
@@ -88,10 +146,26 @@ function isPathLike(value: string): boolean {
|
|
|
88
146
|
value.startsWith("~/") ||
|
|
89
147
|
value === "~" ||
|
|
90
148
|
value.startsWith("./") ||
|
|
91
|
-
value.startsWith("../")
|
|
149
|
+
value.startsWith("../") ||
|
|
150
|
+
isWindowsLikePath(value)
|
|
92
151
|
);
|
|
93
152
|
}
|
|
94
153
|
|
|
154
|
+
function startsWithWindowsPath(text: string, index: number): boolean {
|
|
155
|
+
if (
|
|
156
|
+
index + 2 < text.length &&
|
|
157
|
+
/[a-zA-Z]/.test(text[index]!) &&
|
|
158
|
+
text[index + 1] === ":" &&
|
|
159
|
+
(text[index + 2] === "\\" || text[index + 2] === "/")
|
|
160
|
+
) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
if (index + 1 < text.length && text[index] === "\\" && text[index + 1] === "\\") {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
95
169
|
export function tokenizePathLikeText(text: string): PathToken[] {
|
|
96
170
|
const tokens: PathToken[] = [];
|
|
97
171
|
let index = 0;
|
|
@@ -107,11 +181,12 @@ export function tokenizePathLikeText(text: string): PathToken[] {
|
|
|
107
181
|
if (char === "'" || char === '"') {
|
|
108
182
|
const quote = char;
|
|
109
183
|
index++;
|
|
184
|
+
const windowsMode = startsWithWindowsPath(text, index);
|
|
110
185
|
let value = "";
|
|
111
186
|
let closed = false;
|
|
112
187
|
while (index < text.length) {
|
|
113
188
|
const current = text[index]!;
|
|
114
|
-
if (current === "\\" && quote === '"' && index + 1 < text.length) {
|
|
189
|
+
if (!windowsMode && current === "\\" && quote === '"' && index + 1 < text.length) {
|
|
115
190
|
value += text[index + 1]!;
|
|
116
191
|
index += 2;
|
|
117
192
|
continue;
|
|
@@ -125,15 +200,16 @@ export function tokenizePathLikeText(text: string): PathToken[] {
|
|
|
125
200
|
index++;
|
|
126
201
|
}
|
|
127
202
|
if (closed && isPathLike(value))
|
|
128
|
-
tokens.push({ raw: text.slice(start, index), value, start, end: index });
|
|
203
|
+
tokens.push({ raw: text.slice(start, index), value, start, end: index, bare: false });
|
|
129
204
|
continue;
|
|
130
205
|
}
|
|
131
206
|
|
|
207
|
+
const windowsMode = startsWithWindowsPath(text, index);
|
|
132
208
|
let rawValue = "";
|
|
133
209
|
while (index < text.length) {
|
|
134
210
|
const current = text[index]!;
|
|
135
211
|
if (/\s/.test(current)) break;
|
|
136
|
-
if (current === "\\" && index + 1 < text.length) {
|
|
212
|
+
if (!windowsMode && current === "\\" && index + 1 < text.length) {
|
|
137
213
|
rawValue += current + text[index + 1]!;
|
|
138
214
|
index += 2;
|
|
139
215
|
continue;
|
|
@@ -141,13 +217,57 @@ export function tokenizePathLikeText(text: string): PathToken[] {
|
|
|
141
217
|
rawValue += current;
|
|
142
218
|
index++;
|
|
143
219
|
}
|
|
144
|
-
const value = shellUnescape(rawValue);
|
|
145
|
-
if (isPathLike(value)) tokens.push({ raw: rawValue, value, start, end: index });
|
|
220
|
+
const value = windowsMode ? rawValue : shellUnescape(rawValue);
|
|
221
|
+
if (isPathLike(value)) tokens.push({ raw: rawValue, value, start, end: index, bare: true });
|
|
146
222
|
}
|
|
147
223
|
|
|
148
224
|
return tokens;
|
|
149
225
|
}
|
|
150
226
|
|
|
227
|
+
function tryExtendBareToken(
|
|
228
|
+
text: string,
|
|
229
|
+
token: PathToken,
|
|
230
|
+
attempt: (path: string) => LoadImageResult,
|
|
231
|
+
): { value: string; end: number; result: LoadImageResult } {
|
|
232
|
+
let value = token.value;
|
|
233
|
+
let end = token.end;
|
|
234
|
+
let lastResult = attempt(value);
|
|
235
|
+
if (lastResult.ok || lastResult.reason === "too-large" || !token.bare) {
|
|
236
|
+
return { value, end, result: lastResult };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let scan = end;
|
|
240
|
+
for (let i = 0; i < MAX_BARE_PATH_EXTENSIONS; i++) {
|
|
241
|
+
let wsEnd = scan;
|
|
242
|
+
while (wsEnd < text.length) {
|
|
243
|
+
const ch = text[wsEnd]!;
|
|
244
|
+
if (ch === "\n" || ch === "\r") break;
|
|
245
|
+
if (!/\s/.test(ch)) break;
|
|
246
|
+
wsEnd++;
|
|
247
|
+
}
|
|
248
|
+
if (wsEnd === scan) break;
|
|
249
|
+
|
|
250
|
+
let wordEnd = wsEnd;
|
|
251
|
+
while (wordEnd < text.length && !/\s/.test(text[wordEnd]!)) wordEnd++;
|
|
252
|
+
if (wordEnd === wsEnd) break;
|
|
253
|
+
|
|
254
|
+
const nextWord = shellUnescape(text.slice(wsEnd, wordEnd));
|
|
255
|
+
if (isPathLike(nextWord)) break;
|
|
256
|
+
|
|
257
|
+
const extendedValue = value + text.slice(scan, wsEnd) + nextWord;
|
|
258
|
+
const candidate = attempt(extendedValue);
|
|
259
|
+
scan = wordEnd;
|
|
260
|
+
if (candidate.ok || candidate.reason === "too-large") {
|
|
261
|
+
return { value: extendedValue, end: wordEnd, result: candidate };
|
|
262
|
+
}
|
|
263
|
+
value = extendedValue;
|
|
264
|
+
end = wordEnd;
|
|
265
|
+
lastResult = candidate;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { value, end, result: lastResult };
|
|
269
|
+
}
|
|
270
|
+
|
|
151
271
|
export function dimensionsForImage(data: string, mimeType: SupportedImageMimeType) {
|
|
152
272
|
return getImageDimensions(data, mimeType) ?? undefined;
|
|
153
273
|
}
|
|
@@ -202,16 +322,18 @@ export function replaceImagePathsInText(
|
|
|
202
322
|
const loadImage = options.loadImage ?? loadImageFromPath;
|
|
203
323
|
|
|
204
324
|
for (const token of tokens) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
325
|
+
if (token.start < cursor) continue;
|
|
326
|
+
|
|
327
|
+
const extended = tryExtendBareToken(text, token, (path) => loadImage(path, options.cwd));
|
|
328
|
+
if (!extended.result.ok) {
|
|
329
|
+
if (extended.result.reason === "too-large") options.onReject?.(extended.result);
|
|
208
330
|
continue;
|
|
209
331
|
}
|
|
210
332
|
|
|
211
|
-
const attachment = options.store.add(result.image);
|
|
333
|
+
const attachment = options.store.add(extended.result.image);
|
|
212
334
|
accepted.push(attachment);
|
|
213
335
|
output += text.slice(cursor, token.start) + attachment.placeholder;
|
|
214
|
-
cursor =
|
|
336
|
+
cursor = extended.end;
|
|
215
337
|
replaced++;
|
|
216
338
|
}
|
|
217
339
|
|
|
@@ -234,3 +356,62 @@ export function imagesForText(
|
|
|
234
356
|
})),
|
|
235
357
|
];
|
|
236
358
|
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Async variant of imagesForText that runs each attachment through the
|
|
362
|
+
* Anthropic-aware image optimizer (resize to 8000px cap, JPEG ladder to stay
|
|
363
|
+
* under the 5 MB / 32 MB request caps). Optimization is cached on the
|
|
364
|
+
* attachment so the cost is paid once per image, not per submit.
|
|
365
|
+
*
|
|
366
|
+
* Used by paster's `input` handler; safe to await on the hot path because
|
|
367
|
+
* sharp is only invoked when the image is actually over the limits.
|
|
368
|
+
*/
|
|
369
|
+
export async function imagesForTextOptimized(
|
|
370
|
+
store: AttachmentStore,
|
|
371
|
+
text: string,
|
|
372
|
+
existing: PasterImageContent[] = [],
|
|
373
|
+
): Promise<PasterImageContent[]> {
|
|
374
|
+
const attachments = store.matchingPlaceholders(text);
|
|
375
|
+
const optimized: PasterImageContent[] = [];
|
|
376
|
+
for (const attachment of attachments) {
|
|
377
|
+
if (!attachment.optimized) {
|
|
378
|
+
try {
|
|
379
|
+
const input = Buffer.from(attachment.data, "base64");
|
|
380
|
+
const result = await optimizeImageBytes(input, attachment.mimeType);
|
|
381
|
+
if (result.changed) {
|
|
382
|
+
attachment.data = result.data;
|
|
383
|
+
attachment.mimeType = result.mimeType;
|
|
384
|
+
if (result.finalDim) {
|
|
385
|
+
attachment.dimensions = {
|
|
386
|
+
widthPx: result.finalDim.width,
|
|
387
|
+
heightPx: result.finalDim.height,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
attachment.optimized = true;
|
|
392
|
+
attachment.originalBytes = result.originalBytes;
|
|
393
|
+
attachment.finalBytes = result.finalBytes;
|
|
394
|
+
attachment.optimizeActions = result.actions;
|
|
395
|
+
} catch {
|
|
396
|
+
// optimization is best-effort; fall through with the original bytes
|
|
397
|
+
attachment.optimized = true;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
optimized.push({
|
|
401
|
+
type: "image",
|
|
402
|
+
mimeType: attachment.mimeType,
|
|
403
|
+
data: attachment.data,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return [...existing, ...optimized];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function describeReject(
|
|
410
|
+
result: Exclude<LoadImageResult, { ok: true }>,
|
|
411
|
+
notify?: (message: string) => void,
|
|
412
|
+
): void {
|
|
413
|
+
if (!notify) return;
|
|
414
|
+
if (result.reason === "too-large") {
|
|
415
|
+
notify(`paster: image is too large and was not attached: ${result.path}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
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 { 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";
|
|
@@ -111,15 +112,16 @@ export default function paster(pi: ExtensionAPI, config: PasterConfig = {}): voi
|
|
|
111
112
|
});
|
|
112
113
|
|
|
113
114
|
function previewMessage(attachments: ImageAttachment[]) {
|
|
115
|
+
const placeholders = attachments.map((attachment) => attachment.placeholder);
|
|
114
116
|
return {
|
|
115
117
|
customType: "paster-preview",
|
|
116
|
-
content: ""
|
|
118
|
+
content: `(attachment preview: ${placeholders.join(", ")})`,
|
|
117
119
|
display: true,
|
|
118
|
-
details: { placeholders
|
|
120
|
+
details: { placeholders },
|
|
119
121
|
};
|
|
120
122
|
}
|
|
121
123
|
|
|
122
|
-
pi.on("input", (event, ctx) => {
|
|
124
|
+
pi.on("input", async (event, ctx) => {
|
|
123
125
|
if (event.source === "extension") return { action: "continue" as const };
|
|
124
126
|
if (ctx.hasUI) {
|
|
125
127
|
activeEditor?.clearCursorPreview();
|
|
@@ -136,10 +138,15 @@ export default function paster(pi: ExtensionAPI, config: PasterConfig = {}): voi
|
|
|
136
138
|
pi.sendMessage(previewMessage(attachments), { deliverAs: "followUp" });
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
// Optimize images on-submit so we never exceed Anthropic's 5 MB/image or
|
|
142
|
+
// 32 MB/request caps. Per-attachment caching means each image is only
|
|
143
|
+
// resized/recompressed once across the whole session.
|
|
144
|
+
const images = await imagesForTextOptimized(store, event.text, event.images);
|
|
145
|
+
|
|
139
146
|
return {
|
|
140
147
|
action: "transform" as const,
|
|
141
148
|
text: event.text,
|
|
142
|
-
images
|
|
149
|
+
images,
|
|
143
150
|
};
|
|
144
151
|
});
|
|
145
152
|
|
|
@@ -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
|
@@ -37,10 +37,11 @@ export class ImagePreviewMessage implements Component {
|
|
|
37
37
|
|
|
38
38
|
render(width: number): string[] {
|
|
39
39
|
const lines: string[] = [];
|
|
40
|
+
const safeWidth = Math.max(1, width);
|
|
40
41
|
for (let index = 0; index < this.attachments.length; index++) {
|
|
41
42
|
const attachment = this.attachments[index]!;
|
|
42
|
-
lines.push(formatAttachmentLine(attachment,
|
|
43
|
-
lines.push(...this.images[index]!.render(
|
|
43
|
+
lines.push(formatAttachmentLine(attachment, safeWidth, this.theme.fallbackColor));
|
|
44
|
+
lines.push(...this.images[index]!.render(safeWidth));
|
|
44
45
|
}
|
|
45
46
|
return lines;
|
|
46
47
|
}
|