pi-image-tools 1.0.11 → 1.2.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.
@@ -1,9 +1,8 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
2
  import { tmpdir } from "node:os";
4
3
  import { join } from "node:path";
5
4
 
6
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
6
  import {
8
7
  calculateImageRows,
9
8
  Container,
@@ -13,8 +12,14 @@ import {
13
12
  Spacer,
14
13
  Text,
15
14
  type Component,
16
- } from "@mariozechner/pi-tui";
17
-
15
+ } from "@earendil-works/pi-tui";
16
+
17
+ import { isRecord } from "./config.js";
18
+ import type { DebugLogger } from "./debug-logger.js";
19
+ import { getErrorMessage } from "./errors.js";
20
+ import { getBase64DecodedByteLength, assertImageWithinByteLimit } from "./image-size.js";
21
+ import { mimeTypeToExtension } from "./image-mime.js";
22
+ import { runBufferedCommand, runPowerShellCommandAsync, type BufferedCommandResult, type PowerShellCommandResult, type RunPowerShellCommandOptions } from "./powershell.js";
18
23
  import { buildSixelRenderLines, ensureCompleteSixelSequence } from "./sixel-protocol.js";
19
24
  import {
20
25
  DEFAULT_TERMINAL_IMAGE_WIDTH_CELLS,
@@ -26,6 +31,8 @@ export const IMAGE_PREVIEW_CUSTOM_TYPE = "pi-image-tools-preview";
26
31
  const MAX_IMAGES_PER_MESSAGE = 3;
27
32
  const POWER_SHELL_TIMEOUT_MS = 120_000;
28
33
  const POWER_SHELL_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
34
+ const LINUX_SIXEL_TIMEOUT_MS = 120_000;
35
+ const LINUX_SIXEL_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
29
36
  const FORCE_SIXEL_ENV_VAR = "PI_IMAGE_TOOLS_FORCE_SIXEL";
30
37
  const DISABLE_SIXEL_ENV_VAR = "PI_IMAGE_TOOLS_DISABLE_SIXEL";
31
38
 
@@ -35,9 +42,22 @@ export type ImagePayload = {
35
42
  mimeType: string;
36
43
  };
37
44
 
45
+ type SixelConverter = "powershell-sixel" | "img2sixel";
46
+ type SixelProcessRunner = (
47
+ command: string,
48
+ args: readonly string[],
49
+ options: { timeout: number; maxBuffer: number; windowsHide?: boolean },
50
+ ) => Promise<BufferedCommandResult>;
51
+
52
+ type SixelPowerShellRunner = (
53
+ script: string,
54
+ options: RunPowerShellCommandOptions,
55
+ ) => Promise<PowerShellCommandResult>;
56
+
38
57
  type SixelAvailability = {
39
58
  checked: boolean;
40
59
  available: boolean;
60
+ converter?: SixelConverter;
41
61
  version?: string;
42
62
  reason?: string;
43
63
  };
@@ -77,22 +97,6 @@ function normalizeText(value: unknown): string {
77
97
  return typeof value === "string" ? value.trim() : "";
78
98
  }
79
99
 
80
- function getErrorMessage(error: unknown): string {
81
- if (error instanceof Error && error.message.trim()) {
82
- return error.message;
83
- }
84
-
85
- return "Unknown error";
86
- }
87
-
88
- function toRecord(value: unknown): Record<string, unknown> {
89
- if (!value || typeof value !== "object" || Array.isArray(value)) {
90
- return {};
91
- }
92
-
93
- return value as Record<string, unknown>;
94
- }
95
-
96
100
  function normalizeEnvValue(value: string | undefined): string {
97
101
  return typeof value === "string" ? value.trim().toLowerCase() : "";
98
102
  }
@@ -102,87 +106,39 @@ function isTruthyEnvFlag(value: string | undefined): boolean {
102
106
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
103
107
  }
104
108
 
105
- function shouldAttemptSixelRendering(): boolean {
106
- if (process.platform !== "win32") {
109
+ function shouldAttemptSixelRendering(
110
+ environment: NodeJS.ProcessEnv = process.env,
111
+ platform: NodeJS.Platform = process.platform,
112
+ ): boolean {
113
+ if (isTruthyEnvFlag(environment[DISABLE_SIXEL_ENV_VAR])) {
107
114
  return false;
108
115
  }
109
116
 
110
- if (isTruthyEnvFlag(process.env[DISABLE_SIXEL_ENV_VAR])) {
117
+ if (platform !== "win32" && platform !== "linux") {
111
118
  return false;
112
119
  }
113
120
 
114
- if (isTruthyEnvFlag(process.env[FORCE_SIXEL_ENV_VAR])) {
121
+ if (isTruthyEnvFlag(environment[FORCE_SIXEL_ENV_VAR])) {
115
122
  return true;
116
123
  }
117
124
 
118
125
  return !getCapabilities().images;
119
126
  }
120
127
 
121
- function runPowerShellCommand(
122
- script: string,
123
- args: string[] = [],
124
- ): { ok: boolean; stdout: string; stderr: string; reason?: string } {
125
- if (process.platform !== "win32") {
126
- return {
127
- ok: false,
128
- stdout: "",
129
- stderr: "",
130
- reason: "PowerShell-based Sixel rendering is only available on Windows.",
131
- };
132
- }
133
-
134
- const result = spawnSync(
135
- "powershell.exe",
136
- [
137
- "-NoProfile",
138
- "-NonInteractive",
139
- "-ExecutionPolicy",
140
- "Bypass",
141
- "-Command",
142
- script,
143
- ...args,
144
- ],
145
- {
146
- encoding: "utf8",
147
- timeout: POWER_SHELL_TIMEOUT_MS,
148
- maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
149
- windowsHide: true,
150
- },
151
- );
152
-
153
- if (result.error) {
154
- return {
155
- ok: false,
156
- stdout: result.stdout ?? "",
157
- stderr: result.stderr ?? "",
158
- reason: getErrorMessage(result.error),
159
- };
160
- }
161
-
162
- if (result.status !== 0) {
163
- return {
164
- ok: false,
165
- stdout: result.stdout ?? "",
166
- stderr: result.stderr ?? "",
167
- reason: `PowerShell exited with code ${result.status}`,
168
- };
169
- }
170
-
171
- return {
172
- ok: true,
173
- stdout: result.stdout ?? "",
174
- stderr: result.stderr ?? "",
175
- };
176
- }
177
-
178
128
  const sixelAvailabilityState: SixelAvailability = {
179
129
  checked: false,
180
130
  available: false,
181
131
  };
182
132
 
183
- function ensureSixelModuleAvailable(forceRefresh = false): SixelAvailability {
184
- if (sixelAvailabilityState.checked && !forceRefresh) {
185
- return sixelAvailabilityState;
133
+ async function ensureSixelModuleAvailable(
134
+ forceRefresh = false,
135
+ powerShellRunner: SixelPowerShellRunner = runPowerShellCommandAsync,
136
+ ): Promise<SixelAvailability> {
137
+ const useCache = powerShellRunner === runPowerShellCommandAsync;
138
+ const state: SixelAvailability = useCache ? sixelAvailabilityState : { checked: false, available: false };
139
+
140
+ if (state.checked && !forceRefresh) {
141
+ return state;
186
142
  }
187
143
 
188
144
  const script = `
@@ -191,78 +147,114 @@ $ProgressPreference = 'SilentlyContinue'
191
147
 
192
148
  $module = Get-Module -ListAvailable -Name Sixel | Sort-Object Version -Descending | Select-Object -First 1
193
149
  if ($null -eq $module) {
194
- try {
195
- if (Get-Command Install-Module -ErrorAction SilentlyContinue) {
196
- Install-Module -Name Sixel -Scope CurrentUser -Force -AllowClobber -Repository PSGallery -ErrorAction Stop | Out-Null
197
- } elseif (Get-Command Install-PSResource -ErrorAction SilentlyContinue) {
198
- Install-PSResource -Name Sixel -Scope CurrentUser -TrustRepository -Reinstall -Force -ErrorAction Stop | Out-Null
199
- }
200
- } catch {
201
- }
202
-
203
- $module = Get-Module -ListAvailable -Name Sixel | Sort-Object Version -Descending | Select-Object -First 1
204
- }
205
-
206
- if ($null -eq $module) {
207
- Write-Error 'Sixel PowerShell module is unavailable.'
150
+ Write-Error 'Sixel PowerShell module is unavailable. Install the Sixel module manually to enable Sixel previews.'
208
151
  }
209
152
 
210
153
  Write-Output ('Sixel/' + $module.Version.ToString())
211
154
  `;
212
155
 
213
- const result = runPowerShellCommand(script);
214
- sixelAvailabilityState.checked = true;
156
+ const result = await powerShellRunner(script, {
157
+ timeout: POWER_SHELL_TIMEOUT_MS,
158
+ maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
159
+ });
160
+ state.checked = true;
215
161
 
216
162
  if (!result.ok) {
217
163
  const stderr = normalizeText(result.stderr);
218
164
  const stdout = normalizeText(result.stdout);
219
- sixelAvailabilityState.available = false;
220
- sixelAvailabilityState.version = undefined;
221
- sixelAvailabilityState.reason =
222
- stderr || stdout || result.reason || "Failed to detect/install the Sixel PowerShell module.";
223
- return sixelAvailabilityState;
165
+ state.available = false;
166
+ state.converter = undefined;
167
+ state.version = undefined;
168
+ state.reason =
169
+ stderr || stdout || result.reason || "Failed to detect the Sixel PowerShell module.";
170
+ return state;
224
171
  }
225
172
 
226
173
  const marker = normalizeText(result.stdout)
227
174
  .split(/\r?\n/)
228
175
  .find((line) => line.startsWith("Sixel/"));
229
- sixelAvailabilityState.available = true;
230
- sixelAvailabilityState.version = marker ? marker.slice("Sixel/".length) : undefined;
231
- sixelAvailabilityState.reason = undefined;
232
- return sixelAvailabilityState;
176
+ state.available = true;
177
+ state.converter = "powershell-sixel";
178
+ state.version = marker ? marker.slice("Sixel/".length) : undefined;
179
+ state.reason = undefined;
180
+ return state;
233
181
  }
234
182
 
235
- function extensionForImageMimeType(mimeType: string): string {
236
- const normalized = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
237
- switch (normalized) {
238
- case "image/png":
239
- return "png";
240
- case "image/jpeg":
241
- return "jpg";
242
- case "image/webp":
243
- return "webp";
244
- case "image/gif":
245
- return "gif";
246
- case "image/bmp":
247
- return "bmp";
248
- case "image/tiff":
249
- return "tiff";
250
- default:
251
- return "png";
183
+ async function ensureLinuxSixelConverterAvailable(
184
+ forceRefresh = false,
185
+ processRunner: SixelProcessRunner = runBufferedCommand,
186
+ ): Promise<SixelAvailability> {
187
+ const useCache = processRunner === runBufferedCommand;
188
+ const state: SixelAvailability = useCache ? sixelAvailabilityState : { checked: false, available: false };
189
+
190
+ if (state.checked && !forceRefresh) {
191
+ return state;
252
192
  }
193
+
194
+ state.checked = true;
195
+
196
+ const result = await processRunner("img2sixel", ["--version"], {
197
+ timeout: 5_000,
198
+ maxBuffer: 1024 * 1024,
199
+ });
200
+
201
+ if (result.error) {
202
+ state.available = false;
203
+ state.converter = undefined;
204
+ state.version = undefined;
205
+ state.reason = isErrnoLike(result.error, "ENOENT")
206
+ ? "img2sixel is not installed. Install libsixel-bin or an equivalent package to enable Linux Sixel previews."
207
+ : getErrorMessage(result.error);
208
+ return state;
209
+ }
210
+
211
+ if (result.status !== 0) {
212
+ state.available = false;
213
+ state.converter = undefined;
214
+ state.version = undefined;
215
+ state.reason = normalizeText(result.stderr.toString("utf8")) || normalizeText(result.stdout.toString("utf8")) || "img2sixel detection failed.";
216
+ return state;
217
+ }
218
+
219
+ state.available = true;
220
+ state.converter = "img2sixel";
221
+ state.version = normalizeText(result.stdout.toString("utf8")).split(/\r?\n/)[0] || undefined;
222
+ state.reason = undefined;
223
+ return state;
224
+ }
225
+
226
+ function ensureSixelConverterAvailable(
227
+ forceRefresh = false,
228
+ platform: NodeJS.Platform = process.platform,
229
+ processRunner: SixelProcessRunner = runBufferedCommand,
230
+ powerShellRunner: SixelPowerShellRunner = runPowerShellCommandAsync,
231
+ ): Promise<SixelAvailability> {
232
+ if (platform === "linux") {
233
+ return ensureLinuxSixelConverterAvailable(forceRefresh, processRunner);
234
+ }
235
+
236
+ return ensureSixelModuleAvailable(forceRefresh, powerShellRunner);
237
+ }
238
+
239
+ function isErrnoLike(error: unknown, code: string): boolean {
240
+ return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === code;
253
241
  }
254
242
 
255
243
  function escapePowerShellSingleQuoted(value: string): string {
256
244
  return value.replace(/'/g, "''");
257
245
  }
258
246
 
259
- function convertImageToSixelSequence(
247
+ async function convertImageToSixelSequence(
260
248
  image: ImagePayload,
261
- ): { sequence?: string; error?: string } {
249
+ converter: SixelConverter,
250
+ processRunner: SixelProcessRunner = runBufferedCommand,
251
+ powerShellRunner: SixelPowerShellRunner = runPowerShellCommandAsync,
252
+ ): Promise<{ sequence?: string; error?: string }> {
262
253
  const tempBaseDir = mkdtempSync(join(tmpdir(), "pi-image-tools-image-"));
263
- const imagePath = join(tempBaseDir, `preview.${extensionForImageMimeType(image.mimeType)}`);
254
+ const imagePath = join(tempBaseDir, `preview.${mimeTypeToExtension(image.mimeType)}`);
264
255
 
265
256
  try {
257
+ assertImageWithinByteLimit(getBase64DecodedByteLength(image.data), "Preview image");
266
258
  const bytes = Buffer.from(image.data, "base64");
267
259
  if (bytes.length === 0) {
268
260
  return { error: "Image conversion failed: clipboard payload was empty." };
@@ -270,6 +262,33 @@ function convertImageToSixelSequence(
270
262
 
271
263
  writeFileSync(imagePath, bytes);
272
264
 
265
+ if (converter === "img2sixel") {
266
+ const result = await processRunner("img2sixel", [imagePath], {
267
+ timeout: LINUX_SIXEL_TIMEOUT_MS,
268
+ maxBuffer: LINUX_SIXEL_MAX_BUFFER_BYTES,
269
+ });
270
+
271
+ if (result.error) {
272
+ return { error: `Sixel conversion failed: ${getErrorMessage(result.error)}` };
273
+ }
274
+
275
+ if (result.status !== 0) {
276
+ const detail = normalizeText(result.stderr.toString("utf8")) || normalizeText(result.stdout.toString("utf8"));
277
+ return {
278
+ error: detail
279
+ ? `Sixel conversion failed: ${detail}`
280
+ : "Sixel conversion failed for an unknown reason.",
281
+ };
282
+ }
283
+
284
+ const normalized = ensureCompleteSixelSequence(result.stdout.toString("utf8"));
285
+ if (!normalized) {
286
+ return { error: "Sixel conversion produced empty output." };
287
+ }
288
+
289
+ return { sequence: normalized };
290
+ }
291
+
273
292
  const escapedPath = escapePowerShellSingleQuoted(imagePath);
274
293
 
275
294
  const script = `
@@ -292,7 +311,10 @@ if ([string]::IsNullOrWhiteSpace($rendered)) {
292
311
  Write-Output $rendered
293
312
  `;
294
313
 
295
- const result = runPowerShellCommand(script);
314
+ const result = await powerShellRunner(script, {
315
+ timeout: POWER_SHELL_TIMEOUT_MS,
316
+ maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
317
+ });
296
318
  if (!result.ok) {
297
319
  const detail = normalizeText(result.stderr) || normalizeText(result.stdout) || result.reason;
298
320
  return {
@@ -328,15 +350,22 @@ function estimateImageRows(image: ImagePayload, maxWidthCells: number): number {
328
350
  }
329
351
 
330
352
  function parseImagePreviewDetails(value: unknown): ImagePreviewDetails | null {
331
- const record = toRecord(value);
332
- const itemsRaw = record.items;
353
+ if (!isRecord(value)) {
354
+ return null;
355
+ }
356
+
357
+ const itemsRaw = value.items;
333
358
  if (!Array.isArray(itemsRaw)) {
334
359
  return null;
335
360
  }
336
361
 
337
362
  const items: ImagePreviewItem[] = [];
338
363
  for (const raw of itemsRaw) {
339
- const itemRecord = toRecord(raw);
364
+ if (!isRecord(raw)) {
365
+ continue;
366
+ }
367
+
368
+ const itemRecord = raw;
340
369
  const protocol = itemRecord.protocol === "sixel" ? "sixel" : "native";
341
370
  const mimeType = typeof itemRecord.mimeType === "string" ? itemRecord.mimeType : "image/png";
342
371
  const rows =
@@ -378,47 +407,93 @@ function parseImagePreviewDetails(value: unknown): ImagePreviewDetails | null {
378
407
  return { items };
379
408
  }
380
409
 
381
- export type BuildPreviewItemsOptions = TerminalImageWidthOptions;
410
+ export type BuildPreviewItemsOptions = TerminalImageWidthOptions & {
411
+ environment?: NodeJS.ProcessEnv;
412
+ logger?: DebugLogger;
413
+ platform?: NodeJS.Platform;
414
+ sixelProcessRunner?: SixelProcessRunner;
415
+ sixelPowerShellRunner?: SixelPowerShellRunner;
416
+ };
382
417
 
383
- export function buildPreviewItems(
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
+ export async function buildPreviewItems(
384
431
  images: readonly ImagePayload[],
385
432
  options: BuildPreviewItemsOptions = {},
386
- ): ImagePreviewItem[] {
433
+ ): Promise<ImagePreviewItem[]> {
387
434
  const selectedImages = images.slice(0, MAX_IMAGES_PER_MESSAGE);
388
435
  if (selectedImages.length === 0) {
389
436
  return [];
390
437
  }
391
438
 
392
439
  const maxWidthCells = resolveTerminalImageWidthCells(options);
393
- const attemptSixel = shouldAttemptSixelRendering();
394
- const sixelState = attemptSixel ? ensureSixelModuleAvailable() : undefined;
440
+ const platform = options.platform ?? process.platform;
441
+ const processRunner = options.sixelProcessRunner ?? runBufferedCommand;
442
+ const powerShellRunner = options.sixelPowerShellRunner ?? runPowerShellCommandAsync;
443
+ const attemptSixel = shouldAttemptSixelRendering(options.environment, platform);
444
+ const sixelState = attemptSixel
445
+ ? await ensureSixelConverterAvailable(false, platform, processRunner, powerShellRunner)
446
+ : undefined;
447
+
448
+ logSixelEvent(options.logger, "image-preview.sixel.detected", {
449
+ attemptSixel,
450
+ available: sixelState?.available ?? false,
451
+ converter: sixelState?.converter ?? null,
452
+ reason: sixelState?.reason ?? null,
453
+ platform,
454
+ });
395
455
 
396
- return selectedImages.map((image) => {
456
+ const items: ImagePreviewItem[] = [];
457
+ for (const image of selectedImages) {
397
458
  const rows = estimateImageRows(image, maxWidthCells);
398
459
 
399
- if (attemptSixel && sixelState?.available) {
400
- const conversion = convertImageToSixelSequence(image);
460
+ if (attemptSixel && sixelState?.available && sixelState.converter) {
461
+ const conversion = await convertImageToSixelSequence(image, sixelState.converter, processRunner, powerShellRunner);
401
462
  if (conversion.sequence) {
402
- return {
463
+ logSixelEvent(options.logger, "image-preview.sixel.converted", {
464
+ converter: sixelState.converter,
465
+ mimeType: image.mimeType,
466
+ rows,
467
+ maxWidthCells,
468
+ });
469
+ items.push({
403
470
  protocol: "sixel",
404
471
  mimeType: image.mimeType,
405
472
  rows,
406
473
  maxWidthCells,
407
474
  sixelSequence: conversion.sequence,
408
- };
475
+ });
476
+ continue;
409
477
  }
410
478
 
411
- return {
479
+ logSixelEvent(options.logger, "image-preview.sixel.conversion_failed", {
480
+ converter: sixelState.converter,
481
+ mimeType: image.mimeType,
482
+ error: conversion.error ?? "unknown",
483
+ });
484
+
485
+ items.push({
412
486
  protocol: "native",
413
487
  mimeType: image.mimeType,
414
488
  rows,
415
489
  maxWidthCells,
416
490
  data: image.data,
417
491
  warning: conversion.error,
418
- };
492
+ });
493
+ continue;
419
494
  }
420
495
 
421
- return {
496
+ items.push({
422
497
  protocol: "native",
423
498
  mimeType: image.mimeType,
424
499
  rows,
@@ -426,13 +501,34 @@ export function buildPreviewItems(
426
501
  data: image.data,
427
502
  warning:
428
503
  attemptSixel && sixelState && !sixelState.available
429
- ? `Sixel preview unavailable: ${sixelState.reason || "missing PowerShell Sixel module."}`
504
+ ? `Sixel preview unavailable: ${sixelState.reason || "missing Sixel converter."}`
430
505
  : undefined,
431
- };
432
- });
506
+ });
507
+ }
508
+
509
+ return items;
510
+ }
511
+
512
+ export interface RegisterImagePreviewDisplayOptions {
513
+ logger?: DebugLogger;
433
514
  }
434
515
 
435
- export function registerImagePreviewDisplay(pi: ExtensionAPI): void {
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
+ export function registerImagePreviewDisplay(
529
+ pi: ExtensionAPI,
530
+ options: RegisterImagePreviewDisplayOptions = {},
531
+ ): void {
436
532
  let warnedSixelSetup = false;
437
533
 
438
534
  pi.registerMessageRenderer<ImagePreviewDetails>(
@@ -481,17 +577,21 @@ export function registerImagePreviewDisplay(pi: ExtensionAPI): void {
481
577
  );
482
578
 
483
579
  pi.on("session_start", async (_event, ctx) => {
484
- if (!shouldAttemptSixelRendering()) {
485
- return;
486
- }
580
+ try {
581
+ if (!shouldAttemptSixelRendering()) {
582
+ return;
583
+ }
487
584
 
488
- const availability = ensureSixelModuleAvailable();
489
- if (!availability.available && !warnedSixelSetup && ctx.hasUI) {
490
- warnedSixelSetup = true;
491
- ctx.ui.notify(
492
- `Image preview fallback active: ${availability.reason || "Sixel module unavailable."}`,
493
- "warning",
494
- );
585
+ const availability = await ensureSixelConverterAvailable();
586
+ if (!availability.available && !warnedSixelSetup && ctx.hasUI) {
587
+ warnedSixelSetup = true;
588
+ ctx.ui.notify(
589
+ `Image preview fallback active: ${availability.reason || "Sixel module unavailable."}`,
590
+ "warning",
591
+ );
592
+ }
593
+ } catch (error) {
594
+ logPreviewHandlerError(options.logger, "image-preview.session_start_failed", error);
495
595
  }
496
596
  });
497
597
  }
@@ -0,0 +1,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
+ const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
61
+ return Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
62
+ }
63
+