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 +8 -0
- package/README.md +6 -3
- package/package.json +3 -3
- package/src/clipboard.ts +99 -89
- package/src/config.ts +2 -2
- package/src/image-size.ts +62 -63
- package/src/image-transcode.ts +198 -0
- package/src/keybindings.ts +52 -50
- package/src/providers/command-runner.ts +68 -68
- package/src/providers/mac-osascript-pngf.ts +101 -101
- package/src/providers/mac-osascript-publicpng.ts +77 -77
- package/src/providers/mac-pngpaste.ts +69 -69
- package/src/providers/native-module.ts +84 -84
- package/src/providers/powershell-forms.ts +95 -95
- package/src/providers/registry.ts +88 -88
- package/src/providers/types.ts +24 -24
- package/src/providers/wl-paste.ts +81 -81
- package/src/providers/xclip.ts +87 -87
- package/src/recent-images.ts +1 -0
- package/src/shell-environment.ts +75 -75
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
|
-
|
|
129
|
-
|
|
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.
|
|
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 {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.register(new
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
.register(new
|
|
32
|
-
.register(new
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.register(new
|
|
40
|
-
.register(new
|
|
41
|
-
.register(new
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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(
|
|
89
|
+
function getDefaultShortcutConfig(_platform: NodeJS.Platform): ImageToolsShortcutConfig {
|
|
90
90
|
return {
|
|
91
|
-
avoidBuiltinConflicts:
|
|
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
|
-
|
|
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
|
+
}
|