pi-image-tools 1.2.1 → 1.3.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/CHANGELOG.md +23 -0
- package/README.md +10 -7
- package/package.json +69 -69
- package/src/clipboard.ts +54 -291
- package/src/config.ts +24 -15
- package/src/image-size.ts +62 -63
- package/src/image-transcode.ts +198 -0
- package/src/index.ts +115 -32
- package/src/inline-user-preview.ts +225 -69
- package/src/keybindings.ts +61 -60
- package/src/providers/command-runner.ts +68 -0
- package/src/providers/mac-osascript-pngf.ts +101 -0
- package/src/providers/mac-osascript-publicpng.ts +77 -0
- package/src/providers/mac-pngpaste.ts +69 -0
- package/src/providers/native-module.ts +84 -0
- package/src/providers/powershell-forms.ts +95 -0
- package/src/providers/registry.ts +88 -0
- package/src/providers/types.ts +24 -0
- package/src/providers/wl-paste.ts +81 -0
- package/src/providers/xclip.ts +87 -0
- package/src/recent-images.ts +1 -0
- package/src/shell-environment.ts +75 -0
package/src/config.ts
CHANGED
|
@@ -18,6 +18,10 @@ export interface ImageToolsConfig {
|
|
|
18
18
|
shortcuts: ImageToolsShortcutConfig;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export interface LoadImageToolsConfigOptions {
|
|
22
|
+
platform?: NodeJS.Platform;
|
|
23
|
+
}
|
|
24
|
+
|
|
21
25
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
22
26
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
27
|
}
|
|
@@ -34,9 +38,9 @@ function formatConfigPath(path: string, property: string): string {
|
|
|
34
38
|
return `${path}${property.length > 0 ? ` property \"${property}\"` : ""}`;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
function parseBoolean(value: unknown, property: string, path: string): boolean {
|
|
41
|
+
function parseBoolean(value: unknown, property: string, path: string, defaultValue = false): boolean {
|
|
38
42
|
if (value === undefined) {
|
|
39
|
-
return
|
|
43
|
+
return defaultValue;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
if (typeof value !== "boolean") {
|
|
@@ -82,12 +86,17 @@ function parseShortcutList(value: unknown, property: string, path: string): KeyI
|
|
|
82
86
|
return shortcuts;
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
function
|
|
89
|
+
function getDefaultShortcutConfig(_platform: NodeJS.Platform): ImageToolsShortcutConfig {
|
|
90
|
+
return {
|
|
91
|
+
avoidBuiltinConflicts: true,
|
|
92
|
+
suppressBuiltinConflictWarnings: false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseShortcutConfig(value: unknown, path: string, platform: NodeJS.Platform): ImageToolsShortcutConfig {
|
|
97
|
+
const defaults = getDefaultShortcutConfig(platform);
|
|
86
98
|
if (value === undefined) {
|
|
87
|
-
return
|
|
88
|
-
avoidBuiltinConflicts: false,
|
|
89
|
-
suppressBuiltinConflictWarnings: false,
|
|
90
|
-
};
|
|
99
|
+
return defaults;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
if (!isRecord(value)) {
|
|
@@ -98,11 +107,13 @@ function parseShortcutConfig(value: unknown, path: string): ImageToolsShortcutCo
|
|
|
98
107
|
value.avoidBuiltinConflicts,
|
|
99
108
|
"shortcuts.avoidBuiltinConflicts",
|
|
100
109
|
path,
|
|
110
|
+
defaults.avoidBuiltinConflicts,
|
|
101
111
|
);
|
|
102
112
|
const suppressBuiltinConflictWarnings = parseBoolean(
|
|
103
113
|
value.suppressBuiltinConflictWarnings,
|
|
104
114
|
"shortcuts.suppressBuiltinConflictWarnings",
|
|
105
115
|
path,
|
|
116
|
+
defaults.suppressBuiltinConflictWarnings,
|
|
106
117
|
);
|
|
107
118
|
const pasteImage = parseShortcutList(value.pasteImage, "shortcuts.pasteImage", path);
|
|
108
119
|
|
|
@@ -111,31 +122,29 @@ function parseShortcutConfig(value: unknown, path: string): ImageToolsShortcutCo
|
|
|
111
122
|
: { avoidBuiltinConflicts, suppressBuiltinConflictWarnings, pasteImage };
|
|
112
123
|
}
|
|
113
124
|
|
|
114
|
-
function parseConfig(rawConfig: unknown, path: string): ImageToolsConfig {
|
|
125
|
+
function parseConfig(rawConfig: unknown, path: string, platform: NodeJS.Platform): ImageToolsConfig {
|
|
115
126
|
if (!isRecord(rawConfig)) {
|
|
116
127
|
throw new Error(`Invalid pi-image-tools config at ${path}: expected a JSON object.`);
|
|
117
128
|
}
|
|
118
129
|
|
|
119
130
|
return {
|
|
120
131
|
debug: parseBoolean(rawConfig.debug, "debug", path),
|
|
121
|
-
shortcuts: parseShortcutConfig(rawConfig.shortcuts, path),
|
|
132
|
+
shortcuts: parseShortcutConfig(rawConfig.shortcuts, path, platform),
|
|
122
133
|
};
|
|
123
134
|
}
|
|
124
135
|
|
|
125
|
-
export function loadImageToolsConfig(path = getConfigPath()): ImageToolsConfig {
|
|
136
|
+
export function loadImageToolsConfig(path = getConfigPath(), options: LoadImageToolsConfigOptions = {}): ImageToolsConfig {
|
|
137
|
+
const platform = options.platform ?? process.platform;
|
|
126
138
|
if (!existsSync(path)) {
|
|
127
139
|
return {
|
|
128
140
|
debug: false,
|
|
129
|
-
shortcuts:
|
|
130
|
-
avoidBuiltinConflicts: false,
|
|
131
|
-
suppressBuiltinConflictWarnings: false,
|
|
132
|
-
},
|
|
141
|
+
shortcuts: getDefaultShortcutConfig(platform),
|
|
133
142
|
};
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
try {
|
|
137
146
|
const rawConfig = JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
|
138
|
-
return parseConfig(rawConfig, path);
|
|
147
|
+
return parseConfig(rawConfig, path, platform);
|
|
139
148
|
} catch (error) {
|
|
140
149
|
if (error instanceof SyntaxError) {
|
|
141
150
|
throw new Error(`Invalid pi-image-tools config at ${path}: ${error.message}`);
|
package/src/image-size.ts
CHANGED
|
@@ -1,63 +1,62 @@
|
|
|
1
|
-
export const IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR = "PI_IMAGE_TOOLS_MAX_IMAGE_BYTES";
|
|
2
|
-
export const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
3
|
-
|
|
4
|
-
function parseMaxImageBytes(environment: NodeJS.ProcessEnv): number {
|
|
5
|
-
const rawValue = environment[IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR]?.trim();
|
|
6
|
-
if (!rawValue) {
|
|
7
|
-
return DEFAULT_MAX_IMAGE_BYTES;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const parsed = Number(rawValue);
|
|
11
|
-
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
12
|
-
throw new Error(
|
|
13
|
-
`${IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR} must be a positive byte count when set.`,
|
|
14
|
-
);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return Math.floor(parsed);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function getMaxImageBytes(environment: NodeJS.ProcessEnv = process.env): number {
|
|
21
|
-
return parseMaxImageBytes(environment);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function formatByteLimit(bytes: number): string {
|
|
25
|
-
if (bytes < 1024) {
|
|
26
|
-
return `${bytes} B`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const units = ["KB", "MB", "GB"] as const;
|
|
30
|
-
let value = bytes / 1024;
|
|
31
|
-
let unitIndex = 0;
|
|
32
|
-
|
|
33
|
-
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
34
|
-
value /= 1024;
|
|
35
|
-
unitIndex += 1;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function assertImageWithinByteLimit(
|
|
42
|
-
sizeBytes: number,
|
|
43
|
-
label: string,
|
|
44
|
-
environment: NodeJS.ProcessEnv = process.env,
|
|
45
|
-
): void {
|
|
46
|
-
const maxImageBytes = getMaxImageBytes(environment);
|
|
47
|
-
if (sizeBytes > maxImageBytes) {
|
|
48
|
-
throw new Error(
|
|
49
|
-
`${label} is too large (${formatByteLimit(sizeBytes)}). The pi-image-tools limit is ${formatByteLimit(maxImageBytes)}. Set ${IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR} to a larger byte count if needed.`,
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function getBase64DecodedByteLength(base64Data: string): number {
|
|
55
|
-
const normalized = base64Data.trim().replace(/^data:[^,]*,/, "").replace(/\s/g, "");
|
|
56
|
-
if (normalized.length === 0) {
|
|
57
|
-
return 0;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
1
|
+
export const IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR = "PI_IMAGE_TOOLS_MAX_IMAGE_BYTES";
|
|
2
|
+
export const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
3
|
+
|
|
4
|
+
function parseMaxImageBytes(environment: NodeJS.ProcessEnv): number {
|
|
5
|
+
const rawValue = environment[IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR]?.trim();
|
|
6
|
+
if (!rawValue) {
|
|
7
|
+
return DEFAULT_MAX_IMAGE_BYTES;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const parsed = Number(rawValue);
|
|
11
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`${IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR} must be a positive byte count when set.`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return Math.floor(parsed);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getMaxImageBytes(environment: NodeJS.ProcessEnv = process.env): number {
|
|
21
|
+
return parseMaxImageBytes(environment);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatByteLimit(bytes: number): string {
|
|
25
|
+
if (bytes < 1024) {
|
|
26
|
+
return `${bytes} B`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const units = ["KB", "MB", "GB"] as const;
|
|
30
|
+
let value = bytes / 1024;
|
|
31
|
+
let unitIndex = 0;
|
|
32
|
+
|
|
33
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
34
|
+
value /= 1024;
|
|
35
|
+
unitIndex += 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function assertImageWithinByteLimit(
|
|
42
|
+
sizeBytes: number,
|
|
43
|
+
label: string,
|
|
44
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
45
|
+
): void {
|
|
46
|
+
const maxImageBytes = getMaxImageBytes(environment);
|
|
47
|
+
if (sizeBytes > maxImageBytes) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`${label} is too large (${formatByteLimit(sizeBytes)}). The pi-image-tools limit is ${formatByteLimit(maxImageBytes)}. Set ${IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR} to a larger byte count if needed.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getBase64DecodedByteLength(base64Data: string): number {
|
|
55
|
+
const normalized = base64Data.trim().replace(/^data:[^,]*,/, "").replace(/\s/g, "");
|
|
56
|
+
if (normalized.length === 0) {
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return Buffer.from(normalized, "base64").length;
|
|
61
|
+
}
|
|
62
|
+
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { normalizeMimeType } from "./image-mime.js";
|
|
4
|
+
import type { ClipboardImage } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_PRIMARY_TRANSCODE_TOOL = "magick";
|
|
7
|
+
const LEGACY_UNIX_TRANSCODE_TOOL = "convert";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Maximum wall-clock time for a single ImageMagick invocation. Bounded so a
|
|
11
|
+
* malformed input cannot hang the extension indefinitely; well above what a
|
|
12
|
+
* typical clipboard-sized image needs to convert.
|
|
13
|
+
*/
|
|
14
|
+
const TRANSCODE_TIMEOUT_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Maximum buffered output from ImageMagick. PNG re-encoding of a typical
|
|
18
|
+
* Windows screenshot is well under 5 MB; 64 MB gives us several orders of
|
|
19
|
+
* magnitude of headroom while still bounding memory.
|
|
20
|
+
*/
|
|
21
|
+
const TRANSCODE_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* MIME types that the major model providers (Anthropic, OpenAI, Bedrock, Gemini)
|
|
25
|
+
* accept as image input. Anything outside this set must be transcoded before
|
|
26
|
+
* being attached to a user message, otherwise providers return errors such as
|
|
27
|
+
* "Unknown image type: image/bmp".
|
|
28
|
+
*
|
|
29
|
+
* Distinct from `SUPPORTED_IMAGE_MIME_TYPES` in `./image-mime.ts`, which
|
|
30
|
+
* describes formats the *clipboard providers* know how to read (including
|
|
31
|
+
* `image/bmp`). The two sets serve opposite ends of the pipeline.
|
|
32
|
+
*/
|
|
33
|
+
export const MODEL_PROVIDER_IMAGE_MIME_TYPES: ReadonlySet<string> = new Set([
|
|
34
|
+
"image/png",
|
|
35
|
+
"image/jpeg",
|
|
36
|
+
"image/gif",
|
|
37
|
+
"image/webp",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Aliases and legacy MIME spellings that providers or clipboard sources may
|
|
42
|
+
* emit. Canonicalization keeps supported formats on the fast path and gives
|
|
43
|
+
* ImageMagick stable input format names for formats that need transcoding.
|
|
44
|
+
*/
|
|
45
|
+
const MIME_ALIASES: ReadonlyMap<string, string> = new Map([
|
|
46
|
+
["image/jpg", "image/jpeg"],
|
|
47
|
+
["image/pjpeg", "image/jpeg"],
|
|
48
|
+
["image/x-png", "image/png"],
|
|
49
|
+
["image/x-bmp", "image/bmp"],
|
|
50
|
+
["image/x-ms-bmp", "image/bmp"],
|
|
51
|
+
["image/tif", "image/tiff"],
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const IMAGE_MAGICK_INPUT_FORMAT_BY_MIME_TYPE: ReadonlyMap<string, string> = new Map([
|
|
55
|
+
["image/bmp", "bmp"],
|
|
56
|
+
["image/tiff", "tiff"],
|
|
57
|
+
["image/svg+xml", "svg"],
|
|
58
|
+
["image/heic", "heic"],
|
|
59
|
+
["image/heif", "heif"],
|
|
60
|
+
["image/avif", "avif"],
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
function getDefaultTranscodeTools(platform?: NodeJS.Platform): readonly string[] {
|
|
64
|
+
if (platform === "win32") {
|
|
65
|
+
// Windows ships C:\Windows\System32\convert.exe, which is unrelated to
|
|
66
|
+
// ImageMagick and can produce confusing failures. Prefer the modern
|
|
67
|
+
// ImageMagick 7 `magick` launcher by default on Windows; callers that know
|
|
68
|
+
// they have an ImageMagick `convert` on PATH can still pass `tools`.
|
|
69
|
+
return [DEFAULT_PRIMARY_TRANSCODE_TOOL];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return [DEFAULT_PRIMARY_TRANSCODE_TOOL, LEGACY_UNIX_TRANSCODE_TOOL];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function describeTranscodeTools(tools: readonly string[]): string {
|
|
76
|
+
const uniqueTools = [...new Set(tools.length > 0 ? tools : [DEFAULT_PRIMARY_TRANSCODE_TOOL])];
|
|
77
|
+
if (uniqueTools.length === 1) {
|
|
78
|
+
return `\`${uniqueTools[0]}\``;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const quotedTools = uniqueTools.map((tool) => `\`${tool}\``);
|
|
82
|
+
return `${quotedTools.slice(0, -1).join(", ")} or ${quotedTools[quotedTools.length - 1]}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function imageMagickInputFormatForMimeType(canonicalMimeType: string): string {
|
|
86
|
+
const mappedFormat = IMAGE_MAGICK_INPUT_FORMAT_BY_MIME_TYPE.get(canonicalMimeType);
|
|
87
|
+
if (mappedFormat) {
|
|
88
|
+
return mappedFormat;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!canonicalMimeType.startsWith("image/")) {
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const subtype = canonicalMimeType.slice("image/".length);
|
|
96
|
+
return subtype.split("+")[0] ?? "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isMissingCommandError(error: Error): boolean {
|
|
100
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function canonicalizeMimeType(mimeType: string): string {
|
|
104
|
+
const normalized = normalizeMimeType(mimeType);
|
|
105
|
+
return MIME_ALIASES.get(normalized) ?? normalized;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface TranscodeRunner {
|
|
109
|
+
(
|
|
110
|
+
command: string,
|
|
111
|
+
args: readonly string[],
|
|
112
|
+
input: Uint8Array,
|
|
113
|
+
): SpawnSyncReturns<Buffer>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const defaultTranscodeRunner: TranscodeRunner = (command, args, input) =>
|
|
117
|
+
spawnSync(command, args as string[], {
|
|
118
|
+
input: Buffer.from(input),
|
|
119
|
+
maxBuffer: TRANSCODE_MAX_BUFFER_BYTES,
|
|
120
|
+
timeout: TRANSCODE_TIMEOUT_MS,
|
|
121
|
+
windowsHide: true,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export interface TranscodeOptions {
|
|
125
|
+
/** Override the spawn implementation. Mainly for tests. */
|
|
126
|
+
runner?: TranscodeRunner;
|
|
127
|
+
/** Override the list of candidate ImageMagick executables. */
|
|
128
|
+
tools?: readonly string[];
|
|
129
|
+
/** Platform used to choose safe default ImageMagick executable fallbacks. */
|
|
130
|
+
platform?: NodeJS.Platform;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Ensure a clipboard image is in a MIME type accepted by model providers.
|
|
135
|
+
*
|
|
136
|
+
* Images already in a supported format are returned with a canonicalized MIME
|
|
137
|
+
* (lower-cased, parameters stripped, `image/jpg` upgraded to `image/jpeg`) and
|
|
138
|
+
* otherwise unchanged bytes. Other formats (most notably `image/bmp`, which is
|
|
139
|
+
* what WSLg exposes for Windows clipboard images on the Wayland clipboard, and
|
|
140
|
+
* `image/tiff` from some macOS sources) are piped through ImageMagick and
|
|
141
|
+
* re-encoded as PNG.
|
|
142
|
+
*
|
|
143
|
+
* Throws if the source format is unsupported and no ImageMagick binary
|
|
144
|
+
* (`magick` or the legacy `convert`) is available, or if the conversion fails.
|
|
145
|
+
*/
|
|
146
|
+
export function transcodeToSupportedFormat(
|
|
147
|
+
image: ClipboardImage,
|
|
148
|
+
options: TranscodeOptions = {},
|
|
149
|
+
): ClipboardImage {
|
|
150
|
+
const canonicalMimeType = canonicalizeMimeType(image.mimeType);
|
|
151
|
+
|
|
152
|
+
if (MODEL_PROVIDER_IMAGE_MIME_TYPES.has(canonicalMimeType)) {
|
|
153
|
+
// Fast path: bytes are already in a provider-accepted format.
|
|
154
|
+
// Return the canonicalized MIME so downstream consumers don't have to
|
|
155
|
+
// worry about parameters/casing/aliases.
|
|
156
|
+
return canonicalMimeType === image.mimeType
|
|
157
|
+
? image
|
|
158
|
+
: { bytes: image.bytes, mimeType: canonicalMimeType };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const runner = options.runner ?? defaultTranscodeRunner;
|
|
162
|
+
const platform = options.platform ?? process.platform;
|
|
163
|
+
const tools = options.tools ?? getDefaultTranscodeTools(platform);
|
|
164
|
+
|
|
165
|
+
const inputFormat = imageMagickInputFormatForMimeType(canonicalMimeType);
|
|
166
|
+
const inputSpec = inputFormat.length > 0 ? `${inputFormat}:-` : "-";
|
|
167
|
+
|
|
168
|
+
const failures: string[] = [];
|
|
169
|
+
for (const tool of tools) {
|
|
170
|
+
const result = runner(tool, [inputSpec, "png:-"], image.bytes);
|
|
171
|
+
if (result.error) {
|
|
172
|
+
failures.push(`${tool}: ${result.error.message}`);
|
|
173
|
+
if (isMissingCommandError(result.error)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
if (result.status !== 0 || !result.stdout || result.stdout.length === 0) {
|
|
179
|
+
const stderr = result.stderr ? result.stderr.toString("utf8").trim() : "";
|
|
180
|
+
failures.push(`${tool} exited with status ${result.status}${stderr ? `: ${stderr}` : ""}`);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
bytes: new Uint8Array(result.stdout),
|
|
185
|
+
mimeType: "image/png",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const detail = failures.length > 0
|
|
190
|
+
? ` (${failures.join("; ")})`
|
|
191
|
+
: tools.length === 0
|
|
192
|
+
? " (no transcode tools configured)"
|
|
193
|
+
: "";
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Clipboard image is in unsupported format "${image.mimeType}" and could not be transcoded to PNG. ` +
|
|
196
|
+
`Install ImageMagick (${describeTranscodeTools(tools)}) so pi-image-tools can convert images that providers don't accept natively.${detail}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,34 +1,89 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
-
import { readClipboardImage } from "./clipboard.js";
|
|
4
3
|
import { registerPasteImageCommand } from "./commands.js";
|
|
5
4
|
import { loadImageToolsConfig } from "./config.js";
|
|
6
5
|
import { DebugLogger } from "./debug-logger.js";
|
|
7
6
|
import { getErrorMessage } from "./errors.js";
|
|
8
7
|
import { assertImageWithinByteLimit } from "./image-size.js";
|
|
9
|
-
import {
|
|
10
|
-
IMAGE_PREVIEW_CUSTOM_TYPE,
|
|
11
|
-
buildPreviewItems,
|
|
12
|
-
registerImagePreviewDisplay,
|
|
13
|
-
type ImagePayload,
|
|
14
|
-
} from "./image-preview.js";
|
|
15
|
-
import { registerInlineUserImagePreview } from "./inline-user-preview.js";
|
|
16
8
|
import { registerImagePasteKeybindings } from "./keybindings.js";
|
|
17
|
-
import {
|
|
18
|
-
RECENT_IMAGE_CACHE_DIR_ENV_VAR,
|
|
19
|
-
RECENT_IMAGE_ENV_VAR,
|
|
20
|
-
discoverRecentImages,
|
|
21
|
-
formatRecentImageLabel,
|
|
22
|
-
getRecentImageCacheDirectory,
|
|
23
|
-
loadRecentImage,
|
|
24
|
-
persistImageToRecentCache,
|
|
25
|
-
} from "./recent-images.js";
|
|
26
9
|
import type { ClipboardImage, PasteContext } from "./types.js";
|
|
27
10
|
|
|
28
11
|
const IMAGE_ATTACHMENT_INDICATOR = "[ Image Attached]";
|
|
12
|
+
const IMAGE_PREVIEW_CUSTOM_TYPE = "pi-image-tools-preview";
|
|
13
|
+
const RECENT_IMAGE_ENV_VAR = "PI_IMAGE_TOOLS_RECENT_DIRS";
|
|
14
|
+
const RECENT_IMAGE_CACHE_DIR_ENV_VAR = "PI_IMAGE_TOOLS_RECENT_CACHE_DIR";
|
|
15
|
+
|
|
16
|
+
type ImagePayload = {
|
|
17
|
+
type: "image";
|
|
18
|
+
data: string;
|
|
19
|
+
mimeType: string;
|
|
20
|
+
};
|
|
29
21
|
|
|
30
22
|
interface PendingImage extends ImagePayload {}
|
|
31
23
|
|
|
24
|
+
type ClipboardModule = typeof import("./clipboard.js");
|
|
25
|
+
type ImagePreviewModule = typeof import("./image-preview.js");
|
|
26
|
+
type InlineUserPreviewModule = typeof import("./inline-user-preview.js");
|
|
27
|
+
type RecentImagesModule = typeof import("./recent-images.js");
|
|
28
|
+
|
|
29
|
+
let clipboardModulePromise: Promise<ClipboardModule> | undefined;
|
|
30
|
+
let imagePreviewModulePromise: Promise<ImagePreviewModule> | undefined;
|
|
31
|
+
let inlineUserPreviewModulePromise: Promise<InlineUserPreviewModule> | undefined;
|
|
32
|
+
let recentImagesModulePromise: Promise<RecentImagesModule> | undefined;
|
|
33
|
+
const imagePreviewDisplayRegistrations = new WeakSet<ExtensionAPI>();
|
|
34
|
+
const inlineUserPreviewRegistrations = new WeakSet<ExtensionAPI>();
|
|
35
|
+
const previewRegistrationPromises = new WeakMap<ExtensionAPI, Promise<void>>();
|
|
36
|
+
|
|
37
|
+
function loadClipboardModule(): Promise<ClipboardModule> {
|
|
38
|
+
clipboardModulePromise ??= import("./clipboard.js");
|
|
39
|
+
return clipboardModulePromise;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function loadImagePreviewModule(): Promise<ImagePreviewModule> {
|
|
43
|
+
imagePreviewModulePromise ??= import("./image-preview.js");
|
|
44
|
+
return imagePreviewModulePromise;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function loadInlineUserPreviewModule(): Promise<InlineUserPreviewModule> {
|
|
48
|
+
inlineUserPreviewModulePromise ??= import("./inline-user-preview.js");
|
|
49
|
+
return inlineUserPreviewModulePromise;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function loadRecentImagesModule(): Promise<RecentImagesModule> {
|
|
53
|
+
recentImagesModulePromise ??= import("./recent-images.js");
|
|
54
|
+
return recentImagesModulePromise;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function ensureImagePreviewDisplayRegistered(
|
|
58
|
+
pi: ExtensionAPI,
|
|
59
|
+
logger: DebugLogger,
|
|
60
|
+
): Promise<ImagePreviewModule> {
|
|
61
|
+
const module = await loadImagePreviewModule();
|
|
62
|
+
if (!imagePreviewDisplayRegistrations.has(pi)) {
|
|
63
|
+
imagePreviewDisplayRegistrations.add(pi);
|
|
64
|
+
module.registerImagePreviewDisplay(pi, { logger });
|
|
65
|
+
}
|
|
66
|
+
return module;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function ensurePreviewRegistrations(pi: ExtensionAPI, logger: DebugLogger): Promise<void> {
|
|
70
|
+
let registrationPromise = previewRegistrationPromises.get(pi);
|
|
71
|
+
if (!registrationPromise) {
|
|
72
|
+
registrationPromise = (async () => {
|
|
73
|
+
await ensureImagePreviewDisplayRegistered(pi, logger);
|
|
74
|
+
|
|
75
|
+
if (!inlineUserPreviewRegistrations.has(pi)) {
|
|
76
|
+
const module = await loadInlineUserPreviewModule();
|
|
77
|
+
inlineUserPreviewRegistrations.add(pi);
|
|
78
|
+
module.registerInlineUserImagePreview(pi, { logger });
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
previewRegistrationPromises.set(pi, registrationPromise);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await registrationPromise;
|
|
85
|
+
}
|
|
86
|
+
|
|
32
87
|
function imageToBase64(image: ClipboardImage): string {
|
|
33
88
|
assertImageWithinByteLimit(image.bytes.length, "Image attachment");
|
|
34
89
|
return Buffer.from(image.bytes).toString("base64");
|
|
@@ -59,6 +114,20 @@ function removeAttachmentIndicators(text: string): string {
|
|
|
59
114
|
return withoutMarkers.replace(/\n{3,}/g, "\n\n").trim();
|
|
60
115
|
}
|
|
61
116
|
|
|
117
|
+
async function cacheImageForRecentPicker(ctx: PasteContext, image: ClipboardImage): Promise<void> {
|
|
118
|
+
try {
|
|
119
|
+
const { persistImageToRecentCache } = await loadRecentImagesModule();
|
|
120
|
+
persistImageToRecentCache(image);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (ctx.hasUI) {
|
|
123
|
+
ctx.ui.notify(
|
|
124
|
+
`Image attached, but failed to cache for /paste-image recent: ${getErrorMessage(error)}`,
|
|
125
|
+
"warning",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
62
131
|
function queueImageAttachment(
|
|
63
132
|
ctx: PasteContext,
|
|
64
133
|
pendingImages: PendingImage[],
|
|
@@ -73,16 +142,7 @@ function queueImageAttachment(
|
|
|
73
142
|
});
|
|
74
143
|
|
|
75
144
|
if (options.cacheForRecentPicker) {
|
|
76
|
-
|
|
77
|
-
persistImageToRecentCache(image);
|
|
78
|
-
} catch (error) {
|
|
79
|
-
if (ctx.hasUI) {
|
|
80
|
-
ctx.ui.notify(
|
|
81
|
-
`Image attached, but failed to cache for /paste-image recent: ${getErrorMessage(error)}`,
|
|
82
|
-
"warning",
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
145
|
+
void cacheImageForRecentPicker(ctx, image);
|
|
86
146
|
}
|
|
87
147
|
|
|
88
148
|
if (!ctx.hasUI) {
|
|
@@ -93,12 +153,14 @@ function queueImageAttachment(
|
|
|
93
153
|
ctx.ui.notify(successMessage, "info");
|
|
94
154
|
}
|
|
95
155
|
|
|
96
|
-
function buildRecentImageEmptyStateMessage(
|
|
156
|
+
function buildRecentImageEmptyStateMessage(
|
|
157
|
+
searchedDirectories: readonly string[],
|
|
158
|
+
cacheDirectory: string,
|
|
159
|
+
): string {
|
|
97
160
|
const searched =
|
|
98
161
|
searchedDirectories.length > 0
|
|
99
162
|
? searchedDirectories.join("; ")
|
|
100
163
|
: "No directories configured";
|
|
101
|
-
const cacheDirectory = getRecentImageCacheDirectory();
|
|
102
164
|
|
|
103
165
|
return [
|
|
104
166
|
`No recent images found. Searched: ${searched}`,
|
|
@@ -114,6 +176,7 @@ async function showRecentSelectionPreview(
|
|
|
114
176
|
cwd: string,
|
|
115
177
|
logger: DebugLogger,
|
|
116
178
|
): Promise<void> {
|
|
179
|
+
const { buildPreviewItems } = await ensureImagePreviewDisplayRegistered(pi, logger);
|
|
117
180
|
const previewItems = await buildPreviewItems(
|
|
118
181
|
[
|
|
119
182
|
{
|
|
@@ -151,8 +214,15 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
|
151
214
|
pasteImageShortcutsConfigured: config.shortcuts.pasteImage !== undefined,
|
|
152
215
|
});
|
|
153
216
|
|
|
154
|
-
|
|
155
|
-
|
|
217
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
218
|
+
try {
|
|
219
|
+
const { setActiveTerminalImageSettingsCwd } = await import("./terminal-image-width.js");
|
|
220
|
+
setActiveTerminalImageSettingsCwd(ctx.cwd);
|
|
221
|
+
await ensurePreviewRegistrations(pi, logger);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
logger.log("preview.lazy_registration_failed", { error: getErrorMessage(error) });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
156
226
|
|
|
157
227
|
const pasteImageFromClipboard = async (ctx: PasteContext): Promise<void> => {
|
|
158
228
|
if (!ctx.hasUI) {
|
|
@@ -160,6 +230,7 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
|
160
230
|
}
|
|
161
231
|
|
|
162
232
|
try {
|
|
233
|
+
const { readClipboardImage } = await loadClipboardModule();
|
|
163
234
|
const image = await readClipboardImage();
|
|
164
235
|
if (!image) {
|
|
165
236
|
ctx.ui.notify("No image found in clipboard.", "warning");
|
|
@@ -185,9 +256,21 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
|
185
256
|
}
|
|
186
257
|
|
|
187
258
|
try {
|
|
259
|
+
const {
|
|
260
|
+
discoverRecentImages,
|
|
261
|
+
formatRecentImageLabel,
|
|
262
|
+
getRecentImageCacheDirectory,
|
|
263
|
+
loadRecentImage,
|
|
264
|
+
} = await loadRecentImagesModule();
|
|
188
265
|
const discovery = discoverRecentImages();
|
|
189
266
|
if (discovery.candidates.length === 0) {
|
|
190
|
-
ctx.ui.notify(
|
|
267
|
+
ctx.ui.notify(
|
|
268
|
+
buildRecentImageEmptyStateMessage(
|
|
269
|
+
discovery.searchedDirectories,
|
|
270
|
+
getRecentImageCacheDirectory(),
|
|
271
|
+
),
|
|
272
|
+
"warning",
|
|
273
|
+
);
|
|
191
274
|
return;
|
|
192
275
|
}
|
|
193
276
|
|