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.
@@ -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: SixelAvailability = useCache ? sixelAvailabilityState : { checked: false, available: false };
153
+ const state = resolveSixelAvailabilityState(forceRefresh, useCache);
139
154
 
140
- if (state.checked && !forceRefresh) {
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: SixelAvailability = useCache ? sixelAvailabilityState : { checked: false, available: false };
203
+ const state = resolveSixelAvailabilityState(forceRefresh, useCache);
189
204
 
190
- if (state.checked && !forceRefresh) {
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
- logSixelEvent(options.logger, "image-preview.sixel.detected", {
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
- logSixelEvent(options.logger, "image-preview.sixel.converted", {
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
- logSixelEvent(options.logger, "image-preview.sixel.conversion_failed", {
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
- 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,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
- let clipboardModulePromise: Promise<ClipboardModule> | undefined;
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
- 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
- }
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
- function loadRecentImagesModule(): Promise<RecentImagesModule> {
53
- recentImagesModulePromise ??= import("./recent-images.js");
54
- return recentImagesModulePromise;
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 loadImagePreviewModule();
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 (!registrationPromise) {
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 loadInlineUserPreviewModule();
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 loadRecentImagesModule();
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 loadClipboardModule();
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 loadRecentImagesModule();
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(