pi-image-tools 1.3.0 → 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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [1.3.1] - 2026-06-16
6
+
7
+ ### Fixed
8
+ - Default paste-image shortcuts now avoid Pi built-in shortcut conflicts on all platforms, including macOS. Previously macOS defaulted to overriding Pi's built-in `ctrl+v` image paste binding and printed a startup conflict warning. (reported by @gaodes in [#8](https://github.com/MasuRii/pi-image-tools/issues/8))
9
+ - Transcode clipboard images that arrive in MIME types model providers reject (most notably `image/bmp` from WSLg's Wayland clipboard bridge for Windows clipboard images, and `image/tiff` from some macOS sources) into PNG via ImageMagick before attaching them. Previously, pasting a Windows screenshot inside WSL produced an `Unknown image type: image/bmp` error from the model provider that blocked the message from being sent. (contributed by @codeviking428 in [#9](https://github.com/MasuRii/pi-image-tools/pull/9))
10
+
3
11
  ## [1.3.0] - 2026-06-01
4
12
 
5
13
  ### Added
package/README.md CHANGED
@@ -25,6 +25,7 @@ Image attachment and preview extension for the **Pi coding agent**.
25
25
  |----------|-----------------|---------------------|-------|
26
26
  | Windows | Yes | Yes | Uses native clipboard module first, then PowerShell fallback |
27
27
  | Linux | Yes | Yes | Requires a graphical session; uses `wl-paste` or `xclip`, then native module fallback |
28
+ | WSL2 (WSLg) | Yes | Yes | Linux providers see Windows clipboard images as `image/bmp`; install ImageMagick (`magick`/`convert`) so they can be transcoded to PNG before attaching |
28
29
  | macOS | Yes | Yes | Uses `pngpaste` first, then `osascript` and native module fallbacks |
29
30
  | Termux / headless Linux | No | Limited | Clipboard image paste is disabled without a graphical session |
30
31
 
@@ -122,13 +123,15 @@ $env:PI_IMAGE_TOOLS_RECENT_DIRS = "C:\Users\me\Pictures\Screenshots;D:\Shares\Sc
122
123
 
123
124
  ### Runtime config
124
125
 
125
- A config file can be placed at:
126
+ A config file can be placed at the `pi-image-tools` package/install root next to `index.ts`. For npm-installed extensions this is the package directory under Pi's npm install area, for example:
126
127
 
127
128
  ```text
128
- Default global path: ~/.pi/agent/extensions/pi-image-tools/config.json
129
- Actual global path: $PI_CODING_AGENT_DIR/extensions/pi-image-tools/config.json when PI_CODING_AGENT_DIR is set
129
+ ~/.pi/agent/npm/node_modules/pi-image-tools/config.json
130
+ $PI_CODING_AGENT_DIR/npm/node_modules/pi-image-tools/config.json when PI_CODING_AGENT_DIR is set
130
131
  ```
131
132
 
133
+ For a local checkout or extension path, place `config.json` in that `pi-image-tools` directory.
134
+
132
135
  Starter template:
133
136
 
134
137
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-image-tools",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Image attachment and rendering extension for Pi TUI",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -60,8 +60,8 @@
60
60
  ]
61
61
  },
62
62
  "peerDependencies": {
63
- "@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0",
64
- "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0"
63
+ "@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0",
64
+ "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0"
65
65
  },
66
66
  "optionalDependencies": {
67
67
  "@mariozechner/clipboard": "^0.3.9"
package/src/clipboard.ts CHANGED
@@ -1,89 +1,99 @@
1
- import { hasGraphicalSession, isWaylandSession } from "./shell-environment.js";
2
- import { NativeModuleProvider } from "./providers/native-module.js";
3
- import { OsascriptPngfProvider } from "./providers/mac-osascript-pngf.js";
4
- import { OsascriptPublicPngProvider } from "./providers/mac-osascript-publicpng.js";
5
- import { PngpasteProvider } from "./providers/mac-pngpaste.js";
6
- import { PowerShellFormsProvider } from "./providers/powershell-forms.js";
7
- import { ClipboardProviderRegistry } from "./providers/registry.js";
8
- import { WlPasteProvider } from "./providers/wl-paste.js";
9
- import { XclipProvider } from "./providers/xclip.js";
10
- import type { ClipboardImage } from "./types.js";
11
-
12
- export { hasGraphicalSession } from "./shell-environment.js";
13
-
14
- export function buildDefaultClipboardProviderRegistry(
15
- platform: NodeJS.Platform,
16
- environment: NodeJS.ProcessEnv,
17
- ): ClipboardProviderRegistry {
18
- const registry = new ClipboardProviderRegistry();
19
-
20
- if (platform === "win32") {
21
- registry
22
- .register(new NativeModuleProvider({ priority: 10 }))
23
- .register(new PowerShellFormsProvider({ priority: 20 }));
24
- return registry;
25
- }
26
-
27
- if (platform === "linux") {
28
- const waylandFirst = isWaylandSession(environment);
29
- registry
30
- .register(new WlPasteProvider({ priority: waylandFirst ? 10 : 20 }))
31
- .register(new XclipProvider({ priority: waylandFirst ? 20 : 10 }))
32
- .register(new NativeModuleProvider({ priority: 30 }));
33
- return registry;
34
- }
35
-
36
- if (platform === "darwin") {
37
- registry
38
- .register(new PngpasteProvider({ priority: 10 }))
39
- .register(new OsascriptPublicPngProvider({ priority: 20 }))
40
- .register(new OsascriptPngfProvider({ priority: 30 }))
41
- .register(new NativeModuleProvider({ priority: 40 }));
42
- return registry;
43
- }
44
-
45
- registry.register(new NativeModuleProvider({ priority: 100 }));
46
- return registry;
47
- }
48
-
49
- function getUnavailableReaderMessage(platform: NodeJS.Platform): string {
50
- switch (platform) {
51
- case "linux":
52
- return "No Linux clipboard image reader is available. Install wl-clipboard or xclip, or ensure @mariozechner/clipboard is installed.";
53
- case "darwin":
54
- return "No macOS clipboard image reader is available. Install pngpaste, ensure osascript is available, or ensure @mariozechner/clipboard is installed.";
55
- case "win32":
56
- return "No Windows clipboard image reader is available. Ensure PowerShell is available or @mariozechner/clipboard is installed.";
57
- default:
58
- return `Clipboard image paste is not supported on platform: ${platform}`;
59
- }
60
- }
61
-
62
- export async function readClipboardImage(options?: {
63
- environment?: NodeJS.ProcessEnv;
64
- platform?: NodeJS.Platform;
65
- registry?: ClipboardProviderRegistry;
66
- }): Promise<ClipboardImage | null> {
67
- const environment = options?.environment ?? process.env;
68
- const platform = options?.platform ?? process.platform;
69
-
70
- if (environment.TERMUX_VERSION) {
71
- return null;
72
- }
73
-
74
- if (!hasGraphicalSession(platform, environment)) {
75
- throw new Error("Clipboard image paste requires a graphical desktop session with DISPLAY or WAYLAND_DISPLAY.");
76
- }
77
-
78
- const registry = options?.registry ?? buildDefaultClipboardProviderRegistry(platform, environment);
79
- const result = await registry.read({ platform, environment });
80
- if (result.image) {
81
- return result.image;
82
- }
83
-
84
- if (result.attempts.some((attempt) => attempt.available)) {
85
- return null;
86
- }
87
-
88
- throw new Error(getUnavailableReaderMessage(platform));
89
- }
1
+ import { hasGraphicalSession, isWaylandSession } from "./shell-environment.js";
2
+ import { transcodeToSupportedFormat, type TranscodeOptions } from "./image-transcode.js";
3
+ import { NativeModuleProvider } from "./providers/native-module.js";
4
+ import { OsascriptPngfProvider } from "./providers/mac-osascript-pngf.js";
5
+ import { OsascriptPublicPngProvider } from "./providers/mac-osascript-publicpng.js";
6
+ import { PngpasteProvider } from "./providers/mac-pngpaste.js";
7
+ import { PowerShellFormsProvider } from "./providers/powershell-forms.js";
8
+ import { ClipboardProviderRegistry } from "./providers/registry.js";
9
+ import { WlPasteProvider } from "./providers/wl-paste.js";
10
+ import { XclipProvider } from "./providers/xclip.js";
11
+ import type { ClipboardImage } from "./types.js";
12
+
13
+ export { hasGraphicalSession } from "./shell-environment.js";
14
+
15
+ export function buildDefaultClipboardProviderRegistry(
16
+ platform: NodeJS.Platform,
17
+ environment: NodeJS.ProcessEnv,
18
+ ): ClipboardProviderRegistry {
19
+ const registry = new ClipboardProviderRegistry();
20
+
21
+ if (platform === "win32") {
22
+ registry
23
+ .register(new NativeModuleProvider({ priority: 10 }))
24
+ .register(new PowerShellFormsProvider({ priority: 20 }));
25
+ return registry;
26
+ }
27
+
28
+ if (platform === "linux") {
29
+ const waylandFirst = isWaylandSession(environment);
30
+ registry
31
+ .register(new WlPasteProvider({ priority: waylandFirst ? 10 : 20 }))
32
+ .register(new XclipProvider({ priority: waylandFirst ? 20 : 10 }))
33
+ .register(new NativeModuleProvider({ priority: 30 }));
34
+ return registry;
35
+ }
36
+
37
+ if (platform === "darwin") {
38
+ registry
39
+ .register(new PngpasteProvider({ priority: 10 }))
40
+ .register(new OsascriptPublicPngProvider({ priority: 20 }))
41
+ .register(new OsascriptPngfProvider({ priority: 30 }))
42
+ .register(new NativeModuleProvider({ priority: 40 }));
43
+ return registry;
44
+ }
45
+
46
+ registry.register(new NativeModuleProvider({ priority: 100 }));
47
+ return registry;
48
+ }
49
+
50
+ function getUnavailableReaderMessage(platform: NodeJS.Platform): string {
51
+ switch (platform) {
52
+ case "linux":
53
+ return "No Linux clipboard image reader is available. Install wl-clipboard or xclip, or ensure @mariozechner/clipboard is installed.";
54
+ case "darwin":
55
+ return "No macOS clipboard image reader is available. Install pngpaste, ensure osascript is available, or ensure @mariozechner/clipboard is installed.";
56
+ case "win32":
57
+ return "No Windows clipboard image reader is available. Ensure PowerShell is available or @mariozechner/clipboard is installed.";
58
+ default:
59
+ return `Clipboard image paste is not supported on platform: ${platform}`;
60
+ }
61
+ }
62
+
63
+ export async function readClipboardImage(options?: {
64
+ environment?: NodeJS.ProcessEnv;
65
+ platform?: NodeJS.Platform;
66
+ registry?: ClipboardProviderRegistry;
67
+ /**
68
+ * Hooks for the post-read transcoding step that normalizes images into a
69
+ * MIME type accepted by model providers. Mainly for tests; the default
70
+ * implementation shells out to ImageMagick only when the source format isn't
71
+ * already supported.
72
+ */
73
+ transcode?: TranscodeOptions;
74
+ }): Promise<ClipboardImage | null> {
75
+ const environment = options?.environment ?? process.env;
76
+ const platform = options?.platform ?? process.platform;
77
+
78
+ if (environment.TERMUX_VERSION) {
79
+ return null;
80
+ }
81
+
82
+ if (!hasGraphicalSession(platform, environment)) {
83
+ throw new Error("Clipboard image paste requires a graphical desktop session with DISPLAY or WAYLAND_DISPLAY.");
84
+ }
85
+
86
+ const registry = options?.registry ?? buildDefaultClipboardProviderRegistry(platform, environment);
87
+ const result = await registry.read({ platform, environment });
88
+ if (result.image) {
89
+ // Providers (notably wl-paste under WSLg) may return formats like
90
+ // image/bmp that model providers reject. Normalize to PNG when needed.
91
+ return transcodeToSupportedFormat(result.image, { platform, ...options?.transcode });
92
+ }
93
+
94
+ if (result.attempts.some((attempt) => attempt.available)) {
95
+ return null;
96
+ }
97
+
98
+ throw new Error(getUnavailableReaderMessage(platform));
99
+ }
package/src/config.ts CHANGED
@@ -86,9 +86,9 @@ function parseShortcutList(value: unknown, property: string, path: string): KeyI
86
86
  return shortcuts;
87
87
  }
88
88
 
89
- function getDefaultShortcutConfig(platform: NodeJS.Platform): ImageToolsShortcutConfig {
89
+ function getDefaultShortcutConfig(_platform: NodeJS.Platform): ImageToolsShortcutConfig {
90
90
  return {
91
- avoidBuiltinConflicts: platform !== "darwin",
91
+ avoidBuiltinConflicts: true,
92
92
  suppressBuiltinConflictWarnings: false,
93
93
  };
94
94
  }
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
- const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
61
- return Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
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
+ }