pi-image-tools 1.3.0 → 1.4.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/CHANGELOG.md +135 -115
- package/README.md +16 -8
- package/package.json +13 -4
- package/src/clipboard.ts +99 -89
- package/src/config.ts +5 -2
- package/src/debug-logger.ts +4 -2
- package/src/image-preview.ts +25 -32
- package/src/image-size.ts +62 -63
- package/src/image-transcode.ts +206 -0
- package/src/index.ts +23 -27
- package/src/inline-user-preview.ts +512 -523
- package/src/keybindings.ts +52 -50
- package/src/powershell.ts +82 -56
- package/src/preview-logging.ts +28 -0
- package/src/providers/command-runner.ts +86 -68
- package/src/providers/mac-osascript-pngf.ts +17 -56
- package/src/providers/mac-osascript-publicpng.ts +16 -55
- package/src/providers/mac-pngpaste.ts +16 -55
- package/src/providers/native-module.ts +84 -84
- package/src/providers/powershell-forms.ts +87 -95
- package/src/providers/provider-helpers.ts +196 -0
- package/src/providers/registry.ts +88 -88
- package/src/providers/types.ts +24 -24
- package/src/providers/wl-paste.ts +21 -56
- package/src/providers/xclip.ts +21 -57
- package/src/recent-images.ts +55 -83
- package/src/shell-environment.ts +75 -75
package/src/image-preview.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { DebugLogger } from "./debug-logger.js";
|
|
|
19
19
|
import { getErrorMessage } from "./errors.js";
|
|
20
20
|
import { getBase64DecodedByteLength, assertImageWithinByteLimit } from "./image-size.js";
|
|
21
21
|
import { mimeTypeToExtension } from "./image-mime.js";
|
|
22
|
+
import { logPreviewEvent, logPreviewHandlerError } from "./preview-logging.js";
|
|
22
23
|
import { runBufferedCommand, runPowerShellCommandAsync, type BufferedCommandResult, type PowerShellCommandResult, type RunPowerShellCommandOptions } from "./powershell.js";
|
|
23
24
|
import { buildSixelRenderLines, ensureCompleteSixelSequence } from "./sixel-protocol.js";
|
|
24
25
|
import {
|
|
@@ -130,14 +131,28 @@ const sixelAvailabilityState: SixelAvailability = {
|
|
|
130
131
|
available: false,
|
|
131
132
|
};
|
|
132
133
|
|
|
134
|
+
function resolveSixelAvailabilityState(
|
|
135
|
+
forceRefresh: boolean,
|
|
136
|
+
useCache: boolean,
|
|
137
|
+
): SixelAvailability {
|
|
138
|
+
return useCache ? sixelAvailabilityState : { checked: false, available: false };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function shouldShortCircuitSixelCheck(
|
|
142
|
+
state: SixelAvailability,
|
|
143
|
+
forceRefresh: boolean,
|
|
144
|
+
): boolean {
|
|
145
|
+
return state.checked && !forceRefresh;
|
|
146
|
+
}
|
|
147
|
+
|
|
133
148
|
async function ensureSixelModuleAvailable(
|
|
134
149
|
forceRefresh = false,
|
|
135
150
|
powerShellRunner: SixelPowerShellRunner = runPowerShellCommandAsync,
|
|
136
151
|
): Promise<SixelAvailability> {
|
|
137
152
|
const useCache = powerShellRunner === runPowerShellCommandAsync;
|
|
138
|
-
const state
|
|
153
|
+
const state = resolveSixelAvailabilityState(forceRefresh, useCache);
|
|
139
154
|
|
|
140
|
-
if (state
|
|
155
|
+
if (shouldShortCircuitSixelCheck(state, forceRefresh)) {
|
|
141
156
|
return state;
|
|
142
157
|
}
|
|
143
158
|
|
|
@@ -185,9 +200,9 @@ async function ensureLinuxSixelConverterAvailable(
|
|
|
185
200
|
processRunner: SixelProcessRunner = runBufferedCommand,
|
|
186
201
|
): Promise<SixelAvailability> {
|
|
187
202
|
const useCache = processRunner === runBufferedCommand;
|
|
188
|
-
const state
|
|
203
|
+
const state = resolveSixelAvailabilityState(forceRefresh, useCache);
|
|
189
204
|
|
|
190
|
-
if (state
|
|
205
|
+
if (shouldShortCircuitSixelCheck(state, forceRefresh)) {
|
|
191
206
|
return state;
|
|
192
207
|
}
|
|
193
208
|
|
|
@@ -335,7 +350,9 @@ Write-Output $rendered
|
|
|
335
350
|
} finally {
|
|
336
351
|
try {
|
|
337
352
|
rmSync(tempBaseDir, { recursive: true, force: true });
|
|
338
|
-
} catch {
|
|
353
|
+
} catch (error) {
|
|
354
|
+
// Temp-directory cleanup is best-effort; the OS reclaims leftover dirs.
|
|
355
|
+
void error;
|
|
339
356
|
}
|
|
340
357
|
}
|
|
341
358
|
}
|
|
@@ -415,18 +432,6 @@ export type BuildPreviewItemsOptions = TerminalImageWidthOptions & {
|
|
|
415
432
|
sixelPowerShellRunner?: SixelPowerShellRunner;
|
|
416
433
|
};
|
|
417
434
|
|
|
418
|
-
function logSixelEvent(
|
|
419
|
-
logger: DebugLogger | undefined,
|
|
420
|
-
event: string,
|
|
421
|
-
fields: Record<string, unknown> = {},
|
|
422
|
-
): void {
|
|
423
|
-
try {
|
|
424
|
-
logger?.log(event, fields);
|
|
425
|
-
} catch {
|
|
426
|
-
// Debug logging is best-effort and must never block image rendering.
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
435
|
export async function buildPreviewItems(
|
|
431
436
|
images: readonly ImagePayload[],
|
|
432
437
|
options: BuildPreviewItemsOptions = {},
|
|
@@ -445,7 +450,7 @@ export async function buildPreviewItems(
|
|
|
445
450
|
? await ensureSixelConverterAvailable(false, platform, processRunner, powerShellRunner)
|
|
446
451
|
: undefined;
|
|
447
452
|
|
|
448
|
-
|
|
453
|
+
logPreviewEvent(options.logger, "image-preview.sixel.detected", {
|
|
449
454
|
attemptSixel,
|
|
450
455
|
available: sixelState?.available ?? false,
|
|
451
456
|
converter: sixelState?.converter ?? null,
|
|
@@ -460,7 +465,7 @@ export async function buildPreviewItems(
|
|
|
460
465
|
if (attemptSixel && sixelState?.available && sixelState.converter) {
|
|
461
466
|
const conversion = await convertImageToSixelSequence(image, sixelState.converter, processRunner, powerShellRunner);
|
|
462
467
|
if (conversion.sequence) {
|
|
463
|
-
|
|
468
|
+
logPreviewEvent(options.logger, "image-preview.sixel.converted", {
|
|
464
469
|
converter: sixelState.converter,
|
|
465
470
|
mimeType: image.mimeType,
|
|
466
471
|
rows,
|
|
@@ -476,7 +481,7 @@ export async function buildPreviewItems(
|
|
|
476
481
|
continue;
|
|
477
482
|
}
|
|
478
483
|
|
|
479
|
-
|
|
484
|
+
logPreviewEvent(options.logger, "image-preview.sixel.conversion_failed", {
|
|
480
485
|
converter: sixelState.converter,
|
|
481
486
|
mimeType: image.mimeType,
|
|
482
487
|
error: conversion.error ?? "unknown",
|
|
@@ -513,18 +518,6 @@ export interface RegisterImagePreviewDisplayOptions {
|
|
|
513
518
|
logger?: DebugLogger;
|
|
514
519
|
}
|
|
515
520
|
|
|
516
|
-
function logPreviewHandlerError(
|
|
517
|
-
logger: DebugLogger | undefined,
|
|
518
|
-
event: string,
|
|
519
|
-
error: unknown,
|
|
520
|
-
): void {
|
|
521
|
-
try {
|
|
522
|
-
logger?.log(event, { error: getErrorMessage(error) });
|
|
523
|
-
} catch {
|
|
524
|
-
// Debug logging is best-effort inside Pi event handlers.
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
521
|
export function registerImagePreviewDisplay(
|
|
529
522
|
pi: ExtensionAPI,
|
|
530
523
|
options: RegisterImagePreviewDisplayOptions = {},
|
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,206 @@
|
|
|
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
|
+
const ALLOWED_TRANSCODE_TOOLS = new Set([DEFAULT_PRIMARY_TRANSCODE_TOOL, LEGACY_UNIX_TRANSCODE_TOOL]);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Maximum wall-clock time for a single ImageMagick invocation. Bounded so a
|
|
12
|
+
* malformed input cannot hang the extension indefinitely; well above what a
|
|
13
|
+
* typical clipboard-sized image needs to convert.
|
|
14
|
+
*/
|
|
15
|
+
const TRANSCODE_TIMEOUT_MS = 30_000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Maximum buffered output from ImageMagick. PNG re-encoding of a typical
|
|
19
|
+
* Windows screenshot is well under 5 MB; 64 MB gives us several orders of
|
|
20
|
+
* magnitude of headroom while still bounding memory.
|
|
21
|
+
*/
|
|
22
|
+
const TRANSCODE_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* MIME types that the major model providers (Anthropic, OpenAI, Bedrock, Gemini)
|
|
26
|
+
* accept as image input. Anything outside this set must be transcoded before
|
|
27
|
+
* being attached to a user message, otherwise providers return errors such as
|
|
28
|
+
* "Unknown image type: image/bmp".
|
|
29
|
+
*
|
|
30
|
+
* Distinct from `SUPPORTED_IMAGE_MIME_TYPES` in `./image-mime.ts`, which
|
|
31
|
+
* describes formats the *clipboard providers* know how to read (including
|
|
32
|
+
* `image/bmp`). The two sets serve opposite ends of the pipeline.
|
|
33
|
+
*/
|
|
34
|
+
export const MODEL_PROVIDER_IMAGE_MIME_TYPES: ReadonlySet<string> = new Set([
|
|
35
|
+
"image/png",
|
|
36
|
+
"image/jpeg",
|
|
37
|
+
"image/gif",
|
|
38
|
+
"image/webp",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Aliases and legacy MIME spellings that providers or clipboard sources may
|
|
43
|
+
* emit. Canonicalization keeps supported formats on the fast path and gives
|
|
44
|
+
* ImageMagick stable input format names for formats that need transcoding.
|
|
45
|
+
*/
|
|
46
|
+
const MIME_ALIASES: ReadonlyMap<string, string> = new Map([
|
|
47
|
+
["image/jpg", "image/jpeg"],
|
|
48
|
+
["image/pjpeg", "image/jpeg"],
|
|
49
|
+
["image/x-png", "image/png"],
|
|
50
|
+
["image/x-bmp", "image/bmp"],
|
|
51
|
+
["image/x-ms-bmp", "image/bmp"],
|
|
52
|
+
["image/tif", "image/tiff"],
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const IMAGE_MAGICK_INPUT_FORMAT_BY_MIME_TYPE: ReadonlyMap<string, string> = new Map([
|
|
56
|
+
["image/bmp", "bmp"],
|
|
57
|
+
["image/tiff", "tiff"],
|
|
58
|
+
["image/svg+xml", "svg"],
|
|
59
|
+
["image/heic", "heic"],
|
|
60
|
+
["image/heif", "heif"],
|
|
61
|
+
["image/avif", "avif"],
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
function getDefaultTranscodeTools(platform?: NodeJS.Platform): readonly string[] {
|
|
65
|
+
if (platform === "win32") {
|
|
66
|
+
// Windows ships C:\Windows\System32\convert.exe, which is unrelated to
|
|
67
|
+
// ImageMagick and can produce confusing failures. Prefer the modern
|
|
68
|
+
// ImageMagick 7 `magick` launcher by default on Windows; callers that know
|
|
69
|
+
// they have an ImageMagick `convert` on PATH can still pass `tools`.
|
|
70
|
+
return [DEFAULT_PRIMARY_TRANSCODE_TOOL];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [DEFAULT_PRIMARY_TRANSCODE_TOOL, LEGACY_UNIX_TRANSCODE_TOOL];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isAllowedTranscodeTool(tool: string): boolean {
|
|
77
|
+
return ALLOWED_TRANSCODE_TOOLS.has(tool.trim().toLowerCase());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function describeTranscodeTools(tools: readonly string[]): string {
|
|
81
|
+
const uniqueTools = [...new Set(tools.length > 0 ? tools : [DEFAULT_PRIMARY_TRANSCODE_TOOL])];
|
|
82
|
+
if (uniqueTools.length === 1) {
|
|
83
|
+
return `\`${uniqueTools[0]}\``;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const quotedTools = uniqueTools.map((tool) => `\`${tool}\``);
|
|
87
|
+
return `${quotedTools.slice(0, -1).join(", ")} or ${quotedTools[quotedTools.length - 1]}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function imageMagickInputFormatForMimeType(canonicalMimeType: string): string {
|
|
91
|
+
const mappedFormat = IMAGE_MAGICK_INPUT_FORMAT_BY_MIME_TYPE.get(canonicalMimeType);
|
|
92
|
+
if (mappedFormat) {
|
|
93
|
+
return mappedFormat;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!canonicalMimeType.startsWith("image/")) {
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const subtype = canonicalMimeType.slice("image/".length);
|
|
101
|
+
return subtype.split("+")[0] ?? "";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isMissingCommandError(error: Error): boolean {
|
|
105
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function canonicalizeMimeType(mimeType: string): string {
|
|
109
|
+
const normalized = normalizeMimeType(mimeType);
|
|
110
|
+
return MIME_ALIASES.get(normalized) ?? normalized;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface TranscodeRunner {
|
|
114
|
+
(
|
|
115
|
+
command: string,
|
|
116
|
+
args: readonly string[],
|
|
117
|
+
input: Uint8Array,
|
|
118
|
+
): SpawnSyncReturns<Buffer>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const defaultTranscodeRunner: TranscodeRunner = (command, args, input) =>
|
|
122
|
+
spawnSync(command, args as string[], { // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- command is validated by isAllowedTranscodeTool before the default runner is reached; args are fixed ImageMagick stdin/stdout specs and shell is disabled.
|
|
123
|
+
input: Buffer.from(input),
|
|
124
|
+
maxBuffer: TRANSCODE_MAX_BUFFER_BYTES,
|
|
125
|
+
timeout: TRANSCODE_TIMEOUT_MS,
|
|
126
|
+
windowsHide: true,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export interface TranscodeOptions {
|
|
130
|
+
/** Override the spawn implementation. Mainly for tests. */
|
|
131
|
+
runner?: TranscodeRunner;
|
|
132
|
+
/** Override the list of candidate ImageMagick executables. */
|
|
133
|
+
tools?: readonly string[];
|
|
134
|
+
/** Platform used to choose safe default ImageMagick executable fallbacks. */
|
|
135
|
+
platform?: NodeJS.Platform;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Ensure a clipboard image is in a MIME type accepted by model providers.
|
|
140
|
+
*
|
|
141
|
+
* Images already in a supported format are returned with a canonicalized MIME
|
|
142
|
+
* (lower-cased, parameters stripped, `image/jpg` upgraded to `image/jpeg`) and
|
|
143
|
+
* otherwise unchanged bytes. Other formats (most notably `image/bmp`, which is
|
|
144
|
+
* what WSLg exposes for Windows clipboard images on the Wayland clipboard, and
|
|
145
|
+
* `image/tiff` from some macOS sources) are piped through ImageMagick and
|
|
146
|
+
* re-encoded as PNG.
|
|
147
|
+
*
|
|
148
|
+
* Throws if the source format is unsupported and no ImageMagick binary
|
|
149
|
+
* (`magick` or the legacy `convert`) is available, or if the conversion fails.
|
|
150
|
+
*/
|
|
151
|
+
export function transcodeToSupportedFormat(
|
|
152
|
+
image: ClipboardImage,
|
|
153
|
+
options: TranscodeOptions = {},
|
|
154
|
+
): ClipboardImage {
|
|
155
|
+
const canonicalMimeType = canonicalizeMimeType(image.mimeType);
|
|
156
|
+
|
|
157
|
+
if (MODEL_PROVIDER_IMAGE_MIME_TYPES.has(canonicalMimeType)) {
|
|
158
|
+
// Fast path: bytes are already in a provider-accepted format.
|
|
159
|
+
// Return the canonicalized MIME so downstream consumers don't have to
|
|
160
|
+
// worry about parameters/casing/aliases.
|
|
161
|
+
return canonicalMimeType === image.mimeType
|
|
162
|
+
? image
|
|
163
|
+
: { bytes: image.bytes, mimeType: canonicalMimeType };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const runner = options.runner ?? defaultTranscodeRunner;
|
|
167
|
+
const platform = options.platform ?? process.platform;
|
|
168
|
+
const tools = options.tools ?? getDefaultTranscodeTools(platform);
|
|
169
|
+
|
|
170
|
+
const inputFormat = imageMagickInputFormatForMimeType(canonicalMimeType);
|
|
171
|
+
const inputSpec = inputFormat.length > 0 ? `${inputFormat}:-` : "-";
|
|
172
|
+
|
|
173
|
+
const failures: string[] = [];
|
|
174
|
+
for (const tool of tools) {
|
|
175
|
+
if (!isAllowedTranscodeTool(tool)) {
|
|
176
|
+
throw new Error(`Transcode tool "${tool}" is not an allowed ImageMagick executable. Allowed tools: ${describeTranscodeTools([...ALLOWED_TRANSCODE_TOOLS])}.`);
|
|
177
|
+
}
|
|
178
|
+
const result = runner(tool, [inputSpec, "png:-"], image.bytes);
|
|
179
|
+
if (result.error) {
|
|
180
|
+
failures.push(`${tool}: ${result.error.message}`);
|
|
181
|
+
if (isMissingCommandError(result.error)) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
if (result.status !== 0 || !result.stdout || result.stdout.length === 0) {
|
|
187
|
+
const stderr = result.stderr ? result.stderr.toString("utf8").trim() : "";
|
|
188
|
+
failures.push(`${tool} exited with status ${result.status}${stderr ? `: ${stderr}` : ""}`);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
bytes: new Uint8Array(result.stdout),
|
|
193
|
+
mimeType: "image/png",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const detail = failures.length > 0
|
|
198
|
+
? ` (${failures.join("; ")})`
|
|
199
|
+
: tools.length === 0
|
|
200
|
+
? " (no transcode tools configured)"
|
|
201
|
+
: "";
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Clipboard image is in unsupported format "${image.mimeType}" and could not be transcoded to PNG. ` +
|
|
204
|
+
`Install ImageMagick (${describeTranscodeTools(tools)}) so pi-image-tools can convert images that providers don't accept natively.${detail}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -26,39 +26,32 @@ type ImagePreviewModule = typeof import("./image-preview.js");
|
|
|
26
26
|
type InlineUserPreviewModule = typeof import("./inline-user-preview.js");
|
|
27
27
|
type RecentImagesModule = typeof import("./recent-images.js");
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
let imagePreviewModulePromise: Promise<ImagePreviewModule> | undefined;
|
|
31
|
-
let inlineUserPreviewModulePromise: Promise<InlineUserPreviewModule> | undefined;
|
|
32
|
-
let recentImagesModulePromise: Promise<RecentImagesModule> | undefined;
|
|
29
|
+
const lazyModules = new Map<string, Promise<unknown>>();
|
|
33
30
|
const imagePreviewDisplayRegistrations = new WeakSet<ExtensionAPI>();
|
|
34
31
|
const inlineUserPreviewRegistrations = new WeakSet<ExtensionAPI>();
|
|
35
32
|
const previewRegistrationPromises = new WeakMap<ExtensionAPI, Promise<void>>();
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
function loadInlineUserPreviewModule(): Promise<InlineUserPreviewModule> {
|
|
48
|
-
inlineUserPreviewModulePromise ??= import("./inline-user-preview.js");
|
|
49
|
-
return inlineUserPreviewModulePromise;
|
|
50
|
-
}
|
|
34
|
+
/**
|
|
35
|
+
* Lazily import and cache a module by key. The factory uses a literal
|
|
36
|
+
* `import("./path.js")` so TypeScript keeps full module-type inference; the
|
|
37
|
+
* key retains a single in-flight promise per module across calls.
|
|
38
|
+
*/
|
|
39
|
+
function loadModule<T>(key: string, factory: () => Promise<T>): Promise<T> {
|
|
40
|
+
const cached = lazyModules.get(key);
|
|
41
|
+
if (cached !== undefined) {
|
|
42
|
+
return cached as Promise<T>;
|
|
43
|
+
}
|
|
51
44
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return
|
|
45
|
+
const promise = factory();
|
|
46
|
+
lazyModules.set(key, promise);
|
|
47
|
+
return promise;
|
|
55
48
|
}
|
|
56
49
|
|
|
57
50
|
async function ensureImagePreviewDisplayRegistered(
|
|
58
51
|
pi: ExtensionAPI,
|
|
59
52
|
logger: DebugLogger,
|
|
60
53
|
): Promise<ImagePreviewModule> {
|
|
61
|
-
const module = await
|
|
54
|
+
const module = await loadModule<ImagePreviewModule>("image-preview", () => import("./image-preview.js"));
|
|
62
55
|
if (!imagePreviewDisplayRegistrations.has(pi)) {
|
|
63
56
|
imagePreviewDisplayRegistrations.add(pi);
|
|
64
57
|
module.registerImagePreviewDisplay(pi, { logger });
|
|
@@ -68,12 +61,12 @@ async function ensureImagePreviewDisplayRegistered(
|
|
|
68
61
|
|
|
69
62
|
async function ensurePreviewRegistrations(pi: ExtensionAPI, logger: DebugLogger): Promise<void> {
|
|
70
63
|
let registrationPromise = previewRegistrationPromises.get(pi);
|
|
71
|
-
if (
|
|
64
|
+
if (registrationPromise === undefined) {
|
|
72
65
|
registrationPromise = (async () => {
|
|
73
66
|
await ensureImagePreviewDisplayRegistered(pi, logger);
|
|
74
67
|
|
|
75
68
|
if (!inlineUserPreviewRegistrations.has(pi)) {
|
|
76
|
-
const module = await
|
|
69
|
+
const module = await loadModule<InlineUserPreviewModule>("inline-user-preview", () => import("./inline-user-preview.js"));
|
|
77
70
|
inlineUserPreviewRegistrations.add(pi);
|
|
78
71
|
module.registerInlineUserImagePreview(pi, { logger });
|
|
79
72
|
}
|
|
@@ -116,7 +109,7 @@ function removeAttachmentIndicators(text: string): string {
|
|
|
116
109
|
|
|
117
110
|
async function cacheImageForRecentPicker(ctx: PasteContext, image: ClipboardImage): Promise<void> {
|
|
118
111
|
try {
|
|
119
|
-
const { persistImageToRecentCache } = await
|
|
112
|
+
const { persistImageToRecentCache } = await loadModule<RecentImagesModule>("recent-images", () => import("./recent-images.js"));
|
|
120
113
|
persistImageToRecentCache(image);
|
|
121
114
|
} catch (error) {
|
|
122
115
|
if (ctx.hasUI) {
|
|
@@ -206,6 +199,9 @@ async function showRecentSelectionPreview(
|
|
|
206
199
|
|
|
207
200
|
export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
208
201
|
const config = loadImageToolsConfig();
|
|
202
|
+
if (!config.enabled) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
209
205
|
const logger = DebugLogger.create(config);
|
|
210
206
|
const pendingImages: PendingImage[] = [];
|
|
211
207
|
|
|
@@ -230,7 +226,7 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
|
230
226
|
}
|
|
231
227
|
|
|
232
228
|
try {
|
|
233
|
-
const { readClipboardImage } = await
|
|
229
|
+
const { readClipboardImage } = await loadModule<ClipboardModule>("clipboard", () => import("./clipboard.js"));
|
|
234
230
|
const image = await readClipboardImage();
|
|
235
231
|
if (!image) {
|
|
236
232
|
ctx.ui.notify("No image found in clipboard.", "warning");
|
|
@@ -261,7 +257,7 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
|
261
257
|
formatRecentImageLabel,
|
|
262
258
|
getRecentImageCacheDirectory,
|
|
263
259
|
loadRecentImage,
|
|
264
|
-
} = await
|
|
260
|
+
} = await loadModule<RecentImagesModule>("recent-images", () => import("./recent-images.js"));
|
|
265
261
|
const discovery = discoverRecentImages();
|
|
266
262
|
if (discovery.candidates.length === 0) {
|
|
267
263
|
ctx.ui.notify(
|