@oh-my-pi/pi-coding-agent 5.0.0 → 5.1.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 CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.1.0] - 2026-01-14
6
+
7
+ ### Changed
8
+
9
+ - Updated light theme colors for WCAG AA compliance (4.5:1 contrast against white background)
10
+ - Changed dequeue hint text from "restore" to "edit all queued messages"
11
+
12
+ ### Fixed
13
+
14
+ - Fixed session selector staying open when current folder has no sessions (shows hint to press Tab)
15
+ - Fixed print mode JSON output to emit session header at start
16
+ - Fixed "database is locked" SQLite errors when running subagents by serializing settings to workers instead of opening the database
17
+ - Fixed `/new` command to create a new session file (previously reused the same file when `--session` was specified)
18
+ - Fixed session selector page up/down navigation
19
+
20
+ ## [5.0.1] - 2026-01-12
21
+ ### Changed
22
+
23
+ - Replaced wasm-vips with Photon for more stable WASM image processing
24
+ - Added graceful fallback to original images when image resizing fails
25
+ - Added error handling for image conversion failures in interactive mode to prevent crashes
26
+ - Replace wasm-vips with Photon for more stable WASM image processing (fixes worker thread crashes)
27
+
5
28
  ## [5.0.0] - 2026-01-12
6
29
 
7
30
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,11 +39,12 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-agent-core": "5.0.0",
43
- "@oh-my-pi/pi-ai": "5.0.0",
44
- "@oh-my-pi/pi-git-tool": "5.0.0",
45
- "@oh-my-pi/pi-tui": "5.0.0",
42
+ "@oh-my-pi/pi-agent-core": "5.1.0",
43
+ "@oh-my-pi/pi-ai": "5.1.0",
44
+ "@oh-my-pi/pi-git-tool": "5.1.0",
45
+ "@oh-my-pi/pi-tui": "5.1.0",
46
46
  "@openai/agents": "^0.3.7",
47
+ "@silvia-odwyer/photon-node": "^0.3.4",
47
48
  "@sinclair/typebox": "^0.34.46",
48
49
  "ajv": "^8.17.1",
49
50
  "chalk": "^5.5.0",
@@ -60,7 +61,6 @@
60
61
  "node-html-parser": "^6.1.13",
61
62
  "smol-toml": "^1.6.0",
62
63
  "strip-ansi": "^7.1.2",
63
- "wasm-vips": "^0.0.16",
64
64
  "winston": "^3.17.0",
65
65
  "winston-daily-rotate-file": "^5.0.0",
66
66
  "zod": "^4.3.5"
@@ -52,13 +52,22 @@ export async function processFileArguments(fileArgs: string[], options?: Process
52
52
  let dimensionNote: string | undefined;
53
53
 
54
54
  if (_autoResizeImages) {
55
- const resized = await resizeImage({ type: "image", data: base64Content, mimeType });
56
- dimensionNote = formatDimensionNote(resized);
57
- attachment = {
58
- type: "image",
59
- mimeType: resized.mimeType,
60
- data: resized.data,
61
- };
55
+ try {
56
+ const resized = await resizeImage({ type: "image", data: base64Content, mimeType });
57
+ dimensionNote = formatDimensionNote(resized);
58
+ attachment = {
59
+ type: "image",
60
+ mimeType: resized.mimeType,
61
+ data: resized.data,
62
+ };
63
+ } catch {
64
+ // Fall back to original image on resize failure
65
+ attachment = {
66
+ type: "image",
67
+ mimeType,
68
+ data: base64Content,
69
+ };
70
+ }
62
71
  } else {
63
72
  attachment = {
64
73
  type: "image",
@@ -173,6 +173,7 @@ export class AgentStorage {
173
173
  this.db.exec(`
174
174
  PRAGMA journal_mode=WAL;
175
175
  PRAGMA synchronous=NORMAL;
176
+ PRAGMA busy_timeout=5000;
176
177
 
177
178
  CREATE TABLE IF NOT EXISTS auth_credentials (
178
179
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -42,6 +42,7 @@ export class HistoryStorage {
42
42
  this.db.exec(`
43
43
  PRAGMA journal_mode=WAL;
44
44
  PRAGMA synchronous=NORMAL;
45
+ PRAGMA busy_timeout=5000;
45
46
 
46
47
  CREATE TABLE IF NOT EXISTS history (
47
48
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -8,7 +8,6 @@ import {
8
8
  getGitHubCopilotBaseUrl,
9
9
  getModels,
10
10
  getProviders,
11
- type KnownProvider,
12
11
  type Model,
13
12
  normalizeDomain,
14
13
  } from "@oh-my-pi/pi-ai";
@@ -250,7 +249,7 @@ export class ModelRegistry {
250
249
  return getProviders()
251
250
  .filter((provider) => !replacedProviders.has(provider))
252
251
  .flatMap((provider) => {
253
- const models = getModels(provider as KnownProvider) as Model<Api>[];
252
+ const models = getModels(provider as any) as Model<Api>[];
254
253
  const override = overrides.get(provider);
255
254
  if (!override) return models;
256
255
 
@@ -11,6 +11,7 @@ import type { ModelRegistry } from "./model-registry";
11
11
 
12
12
  /** Default model IDs for each known provider */
13
13
  export const defaultModelPerProvider: Record<KnownProvider, string> = {
14
+ "amazon-bedrock": "us.anthropic.claude-sonnet-4-5-20250514-v1:0",
14
15
  anthropic: "claude-sonnet-4-5",
15
16
  openai: "gpt-5.1-codex",
16
17
  "openai-codex": "codex-max",
@@ -21,11 +22,13 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
21
22
  "github-copilot": "gpt-4o",
22
23
  cursor: "claude-sonnet-4-5",
23
24
  openrouter: "openai/gpt-5.1-codex",
25
+ "vercel-ai-gateway": "claude-sonnet-4-5",
24
26
  xai: "grok-4-fast-non-reasoning",
25
27
  groq: "openai/gpt-oss-120b",
26
28
  cerebras: "zai-glm-4.6",
27
29
  zai: "glm-4.6",
28
30
  mistral: "devstral-medium-latest",
31
+ minimax: "MiniMax-M2",
29
32
  opencode: "claude-opus-4-5",
30
33
  };
31
34
 
package/src/core/sdk.ts CHANGED
@@ -689,6 +689,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
689
689
  return activeModel ? formatModelString(activeModel) : undefined;
690
690
  },
691
691
  settings: settingsManager,
692
+ settingsManager,
692
693
  authStorage,
693
694
  modelRegistry,
694
695
  };
@@ -894,7 +894,9 @@ export class SessionManager {
894
894
  this._buildIndex();
895
895
  this.flushed = true;
896
896
  } else {
897
+ const explicitPath = this.sessionFile;
897
898
  this._newSessionSync();
899
+ this.sessionFile = explicitPath; // preserve explicit path from --session flag
898
900
  }
899
901
  }
900
902
 
@@ -921,12 +923,12 @@ export class SessionManager {
921
923
  };
922
924
  this.fileEntries = [header];
923
925
  this.byId.clear();
926
+ this.labelsById.clear();
924
927
  this.leafId = null;
925
928
  this.flushed = false;
926
929
  this.usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
927
930
 
928
- // Only generate filename if persisting and not already set (e.g., via --session flag)
929
- if (this.persist && !this.sessionFile) {
931
+ if (this.persist) {
930
932
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
931
933
  this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
932
934
  }
@@ -506,6 +506,14 @@ export class SettingsManager {
506
506
  return new SettingsManager(null, null, settings, false, {});
507
507
  }
508
508
 
509
+ /**
510
+ * Serialize settings for passing to subagent workers.
511
+ * Returns the merged settings (global + project + overrides).
512
+ */
513
+ serialize(): Settings {
514
+ return { ...this.settings };
515
+ }
516
+
509
517
  /**
510
518
  * Load settings from SQLite storage, applying any schema migrations.
511
519
  * @param storage - AgentStorage instance, or null for in-memory mode
@@ -632,6 +632,10 @@ export interface BuildSystemPromptOptions {
632
632
 
633
633
  /** Build the system prompt with tools, guidelines, and context */
634
634
  export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise<string> {
635
+ if (process.env.NULL_PROMPT === "true") {
636
+ return "";
637
+ }
638
+
635
639
  const {
636
640
  customPrompt,
637
641
  tools,
@@ -116,6 +116,8 @@ export interface ToolSession {
116
116
  modelRegistry?: import("../model-registry").ModelRegistry;
117
117
  /** MCP manager for proxying MCP calls through parent */
118
118
  mcpManager?: import("../mcp/manager").MCPManager;
119
+ /** Settings manager for passing to subagents (avoids SQLite access in workers) */
120
+ settingsManager?: { serialize: () => import("../settings-manager").Settings };
119
121
  /** Settings manager (optional) */
120
122
  settings?: {
121
123
  getImageAutoResize(): boolean;
@@ -518,19 +518,27 @@ export function createReadTool(session: ToolSession): AgentTool<typeof readSchem
518
518
  const base64 = Buffer.from(buffer).toString("base64");
519
519
 
520
520
  if (autoResizeImages) {
521
- // Resize image if needed
522
- const resized = await resizeImage({ type: "image", data: base64, mimeType });
523
- const dimensionNote = formatDimensionNote(resized);
524
-
525
- let textNote = `Read image file [${resized.mimeType}]`;
526
- if (dimensionNote) {
527
- textNote += `\n${dimensionNote}`;
521
+ // Resize image if needed - catch errors from WASM
522
+ try {
523
+ const resized = await resizeImage({ type: "image", data: base64, mimeType });
524
+ const dimensionNote = formatDimensionNote(resized);
525
+
526
+ let textNote = `Read image file [${resized.mimeType}]`;
527
+ if (dimensionNote) {
528
+ textNote += `\n${dimensionNote}`;
529
+ }
530
+
531
+ content = [
532
+ { type: "text", text: textNote },
533
+ { type: "image", data: resized.data, mimeType: resized.mimeType },
534
+ ];
535
+ } catch {
536
+ // Fall back to original image on resize failure
537
+ content = [
538
+ { type: "text", text: `Read image file [${mimeType}]` },
539
+ { type: "image", data: base64, mimeType },
540
+ ];
528
541
  }
529
-
530
- content = [
531
- { type: "text", text: textNote },
532
- { type: "image", data: resized.data, mimeType: resized.mimeType },
533
- ];
534
542
  } else {
535
543
  content = [
536
544
  { type: "text", text: `Read image file [${mimeType}]` },
@@ -52,6 +52,7 @@ export interface ExecutorOptions {
52
52
  mcpManager?: MCPManager;
53
53
  authStorage?: AuthStorage;
54
54
  modelRegistry?: ModelRegistry;
55
+ settingsManager?: { serialize: () => import("../../settings-manager").Settings };
55
56
  }
56
57
 
57
58
  /**
@@ -600,6 +601,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
600
601
  enableLsp,
601
602
  serializedAuth: options.authStorage?.serialize(),
602
603
  serializedModels: options.modelRegistry?.serialize(),
604
+ serializedSettings: options.settingsManager?.serialize(),
603
605
  mcpTools: options.mcpManager ? extractMCPToolMetadata(options.mcpManager) : undefined,
604
606
  },
605
607
  };
@@ -364,6 +364,7 @@ export async function createTaskTool(
364
364
  },
365
365
  authStorage: session.authStorage,
366
366
  modelRegistry: session.modelRegistry,
367
+ settingsManager: session.settingsManager,
367
368
  mcpManager: session.mcpManager,
368
369
  });
369
370
  },
@@ -1,6 +1,7 @@
1
1
  import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { SerializedAuthStorage } from "../../auth-storage";
3
3
  import type { SerializedModelRegistry } from "../../model-registry";
4
+ import type { Settings } from "../../settings-manager";
4
5
 
5
6
  /**
6
7
  * MCP tool metadata passed from parent to worker for proxy tool creation.
@@ -51,6 +52,7 @@ export interface SubagentWorkerStartPayload {
51
52
  spawnsEnv?: string;
52
53
  serializedAuth?: SerializedAuthStorage;
53
54
  serializedModels?: SerializedModelRegistry;
55
+ serializedSettings?: Settings;
54
56
  mcpTools?: MCPToolMetadata[];
55
57
  }
56
58
 
@@ -23,6 +23,7 @@ import { ModelRegistry } from "../../model-registry";
23
23
  import { parseModelPattern, parseModelString } from "../../model-resolver";
24
24
  import { createAgentSession, discoverAuthStorage, discoverModels } from "../../sdk";
25
25
  import { SessionManager } from "../../session-manager";
26
+ import { SettingsManager } from "../../settings-manager";
26
27
  import { untilAborted } from "../../utils";
27
28
  import type {
28
29
  MCPToolCallResponse,
@@ -296,6 +297,10 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
296
297
  : SessionManager.inMemory(payload.cwd);
297
298
  checkAbort();
298
299
 
300
+ // Use serialized settings if provided, otherwise use empty in-memory settings
301
+ // This avoids opening the SQLite database in worker threads
302
+ const settingsManager = SettingsManager.inMemory(payload.serializedSettings ?? {});
303
+
299
304
  // Create agent session (equivalent to CLI's createAgentSession)
300
305
  // Note: hasUI: false disables interactive features
301
306
  const completionInstruction =
@@ -305,6 +310,7 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
305
310
  cwd: payload.cwd,
306
311
  authStorage,
307
312
  modelRegistry,
313
+ settingsManager,
308
314
  model,
309
315
  thinkingLevel,
310
316
  toolNames: payload.toolNames,
package/src/index.ts CHANGED
@@ -260,9 +260,11 @@ export {
260
260
  } from "./modes/interactive/components/index";
261
261
  // Theme utilities for custom tools
262
262
  export {
263
+ getLanguageFromPath,
263
264
  getMarkdownTheme,
264
265
  getSelectListTheme,
265
266
  getSettingsListTheme,
267
+ highlightCode,
266
268
  initTheme,
267
269
  Theme,
268
270
  type ThemeColor,
@@ -7,6 +7,8 @@ import {
7
7
  isCtrlC,
8
8
  isEnter,
9
9
  isEscape,
10
+ isPageDown,
11
+ isPageUp,
10
12
  Spacer,
11
13
  Text,
12
14
  truncateToWidth,
@@ -25,14 +27,16 @@ class SessionList implements Component {
25
27
  private filteredSessions: SessionInfo[] = [];
26
28
  private selectedIndex: number = 0;
27
29
  private searchInput: Input;
30
+ private showCwd = false;
28
31
  public onSelect?: (sessionPath: string) => void;
29
32
  public onCancel?: () => void;
30
33
  public onExit: () => void = () => {};
31
34
  private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)
32
35
 
33
- constructor(sessions: SessionInfo[]) {
36
+ constructor(sessions: SessionInfo[], showCwd = false) {
34
37
  this.allSessions = sessions;
35
38
  this.filteredSessions = sessions;
39
+ this.showCwd = showCwd;
36
40
  this.searchInput = new Input();
37
41
 
38
42
  // Handle Enter in search input - select current item
@@ -67,7 +71,13 @@ class SessionList implements Component {
67
71
  lines.push(""); // Blank line after search
68
72
 
69
73
  if (this.filteredSessions.length === 0) {
70
- lines.push(theme.fg("muted", " No sessions found"));
74
+ if (this.showCwd) {
75
+ // "All" scope - no sessions anywhere that match filter
76
+ lines.push(theme.fg("muted", " No sessions found"));
77
+ } else {
78
+ // "Current folder" scope - hint to try "all"
79
+ lines.push(theme.fg("muted", " No sessions in current folder. Press Tab to view all."));
80
+ }
71
81
  return lines;
72
82
  }
73
83
 
@@ -154,6 +164,14 @@ class SessionList implements Component {
154
164
  else if (isArrowDown(keyData)) {
155
165
  this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1);
156
166
  }
167
+ // Page up - jump up by maxVisible items
168
+ else if (isPageUp(keyData)) {
169
+ this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible);
170
+ }
171
+ // Page down - jump down by maxVisible items
172
+ else if (isPageDown(keyData)) {
173
+ this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + this.maxVisible);
174
+ }
157
175
  // Enter
158
176
  else if (isEnter(keyData)) {
159
177
  const selected = this.filteredSessions[this.selectedIndex];
@@ -211,11 +229,6 @@ export class SessionSelectorComponent extends Container {
211
229
  // Add bottom border
212
230
  this.addChild(new Spacer(1));
213
231
  this.addChild(new DynamicBorder());
214
-
215
- // Auto-cancel if no sessions
216
- if (sessions.length === 0) {
217
- setTimeout(() => onCancel(), 100);
218
- }
219
232
  }
220
233
 
221
234
  getSessionList(): SessionList {
@@ -261,15 +261,19 @@ export class ToolExecutionComponent extends Container {
261
261
  if (img.mimeType === "image/png") continue;
262
262
  if (this.convertedImages.has(i)) continue;
263
263
 
264
- // Convert async
264
+ // Convert async - catch errors from WASM processing
265
265
  const index = i;
266
- convertToPng(img.data, img.mimeType).then((converted) => {
267
- if (converted) {
268
- this.convertedImages.set(index, converted);
269
- this.updateDisplay();
270
- this.ui.requestRender();
271
- }
272
- });
266
+ convertToPng(img.data, img.mimeType)
267
+ .then((converted) => {
268
+ if (converted) {
269
+ this.convertedImages.set(index, converted);
270
+ this.updateDisplay();
271
+ this.ui.requestRender();
272
+ }
273
+ })
274
+ .catch(() => {
275
+ // Ignore conversion failures - display will use original image format
276
+ });
273
277
  }
274
278
  }
275
279
 
@@ -2,13 +2,13 @@
2
2
  "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/theme-schema.json",
3
3
  "name": "light",
4
4
  "vars": {
5
- "teal": "#5f8787",
6
- "blue": "#5f87af",
7
- "green": "#87af87",
8
- "red": "#af5f5f",
9
- "yellow": "#d7af5f",
5
+ "teal": "#5a8080",
6
+ "blue": "#547da7",
7
+ "green": "#588458",
8
+ "red": "#aa5555",
9
+ "yellow": "#9a7326",
10
10
  "mediumGray": "#6c6c6c",
11
- "dimGray": "#8a8a8a",
11
+ "dimGray": "#767676",
12
12
  "lightGray": "#b0b0b0",
13
13
  "selectedBg": "#d0d0e0",
14
14
  "userMsgBg": "#e8e8e8",
@@ -68,9 +68,9 @@
68
68
  "syntaxPunctuation": "#000000",
69
69
 
70
70
  "thinkingOff": "lightGray",
71
- "thinkingMinimal": "#9e9e9e",
72
- "thinkingLow": "#5f87af",
73
- "thinkingMedium": "#5f8787",
71
+ "thinkingMinimal": "#767676",
72
+ "thinkingLow": "blue",
73
+ "thinkingMedium": "teal",
74
74
  "thinkingHigh": "#875f87",
75
75
  "thinkingXhigh": "#8b008b",
76
76
 
@@ -340,6 +340,8 @@ export class UiHelpers {
340
340
  const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
341
341
  this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
342
342
  }
343
+ const hintText = theme.fg("dim", `${theme.tree.hook} Alt+Up to edit`);
344
+ this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
343
345
  }
344
346
  }
345
347
 
@@ -29,6 +29,14 @@ export interface PrintModeOptions {
29
29
  */
30
30
  export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise<void> {
31
31
  const { mode, messages = [], initialMessage, initialImages } = options;
32
+
33
+ // Emit session header for JSON mode
34
+ if (mode === "json") {
35
+ const header = session.sessionManager.getHeader();
36
+ if (header) {
37
+ process.stdout.write(`${JSON.stringify(header)}\n`);
38
+ }
39
+ }
32
40
  // Set up extensions for print mode (no UI, no command context)
33
41
  const extensionRunner = session.extensionRunner;
34
42
  if (extensionRunner) {
@@ -1,11 +1,8 @@
1
- import { logger } from "../core/logger";
2
- import { convertToPngWithImageMagick } from "./image-magick";
3
- import { Vips } from "./vips";
1
+ import photon from "@silvia-odwyer/photon-node";
4
2
 
5
3
  /**
6
4
  * Convert image to PNG format for terminal display.
7
5
  * Kitty graphics protocol requires PNG format (f=100).
8
- * Uses wasm-vips if available, falls back to ImageMagick (magick/convert).
9
6
  */
10
7
  export async function convertToPng(
11
8
  base64Data: string,
@@ -17,24 +14,18 @@ export async function convertToPng(
17
14
  }
18
15
 
19
16
  try {
20
- const { Image } = await Vips();
21
- const image = Image.newFromBuffer(Buffer.from(base64Data, "base64"));
17
+ const image = photon.PhotonImage.new_from_byteslice(new Uint8Array(Buffer.from(base64Data, "base64")));
22
18
  try {
23
- const pngBuffer = image.writeToBuffer(".png");
19
+ const pngBuffer = image.get_bytes();
24
20
  return {
25
21
  data: Buffer.from(pngBuffer).toString("base64"),
26
22
  mimeType: "image/png",
27
23
  };
28
24
  } finally {
29
- image.delete();
25
+ image.free();
30
26
  }
31
- } catch (error) {
32
- // wasm-vips failed, try ImageMagick fallback
33
- logger.error("Failed to convert image to PNG with wasm-vips", {
34
- error: error instanceof Error ? error.message : String(error),
35
- });
27
+ } catch {
28
+ // Conversion failed
29
+ return null;
36
30
  }
37
-
38
- // Fall back to ImageMagick
39
- return convertToPngWithImageMagick(base64Data, mimeType);
40
31
  }
@@ -1,7 +1,5 @@
1
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
- import { logger } from "../core/logger";
3
- import { getImageDimensionsWithImageMagick, resizeWithImageMagick } from "./image-magick";
4
- import { Vips } from "./vips";
2
+ import photon from "@silvia-odwyer/photon-node";
5
3
 
6
4
  export interface ImageResizeOptions {
7
5
  maxWidth?: number; // Default: 2000
@@ -30,49 +28,6 @@ const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
30
28
  jpegQuality: 80,
31
29
  };
32
30
 
33
- /**
34
- * Fallback resize using ImageMagick when wasm-vips is unavailable.
35
- */
36
- async function resizeImageWithImageMagick(
37
- img: ImageContent,
38
- opts: Required<ImageResizeOptions>,
39
- ): Promise<ResizedImage> {
40
- const dims = await getImageDimensionsWithImageMagick(img.data);
41
- const originalWidth = dims?.width ?? 0;
42
- const originalHeight = dims?.height ?? 0;
43
-
44
- const result = await resizeWithImageMagick(
45
- img.data,
46
- img.mimeType,
47
- opts.maxWidth,
48
- opts.maxHeight,
49
- opts.maxBytes,
50
- opts.jpegQuality,
51
- );
52
-
53
- if (result) {
54
- return {
55
- data: result.data,
56
- mimeType: result.mimeType,
57
- originalWidth,
58
- originalHeight,
59
- width: result.width,
60
- height: result.height,
61
- wasResized: true,
62
- };
63
- }
64
-
65
- return {
66
- data: img.data,
67
- mimeType: img.mimeType,
68
- originalWidth,
69
- originalHeight,
70
- width: originalWidth,
71
- height: originalHeight,
72
- wasResized: false,
73
- };
74
- }
75
-
76
31
  /** Helper to pick the smaller of two buffers */
77
32
  function pickSmaller(
78
33
  a: { buffer: Uint8Array; mimeType: string },
@@ -85,7 +40,8 @@ function pickSmaller(
85
40
  * Resize an image to fit within the specified max dimensions and file size.
86
41
  * Returns the original image if it already fits within the limits.
87
42
  *
88
- * Uses wasm-vips for image processing. Falls back to ImageMagick if unavailable.
43
+ * Uses Photon (Rust/WASM) for image processing. If Photon is not available,
44
+ * returns the original image unchanged.
89
45
  *
90
46
  * Strategy for staying under maxBytes:
91
47
  * 1. First resize to maxWidth/maxHeight
@@ -95,73 +51,90 @@ function pickSmaller(
95
51
  */
96
52
  export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {
97
53
  const opts = { ...DEFAULT_OPTIONS, ...options };
98
- const buffer = Buffer.from(img.data, "base64");
54
+ const inputBuffer = Buffer.from(img.data, "base64");
99
55
 
56
+ let image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;
100
57
  try {
101
- const { Image } = await Vips();
102
- const image = Image.newFromBuffer(buffer);
103
- try {
104
- const originalWidth = image.width;
105
- const originalHeight = image.height;
106
- const format = img.mimeType?.split("/")[1] ?? "png";
107
-
108
- // Check if already within all limits (dimensions AND size)
109
- const originalSize = buffer.length;
110
- if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
111
- return {
112
- data: img.data,
113
- mimeType: img.mimeType ?? `image/${format}`,
114
- originalWidth,
115
- originalHeight,
116
- width: originalWidth,
117
- height: originalHeight,
118
- wasResized: false,
119
- };
120
- }
58
+ image = photon.PhotonImage.new_from_byteslice(new Uint8Array(inputBuffer));
121
59
 
122
- // Calculate initial dimensions respecting max limits
123
- let targetWidth = originalWidth;
124
- let targetHeight = originalHeight;
60
+ const originalWidth = image.get_width();
61
+ const originalHeight = image.get_height();
62
+ const format = img.mimeType?.split("/")[1] ?? "png";
125
63
 
126
- if (targetWidth > opts.maxWidth) {
127
- targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
128
- targetWidth = opts.maxWidth;
129
- }
130
- if (targetHeight > opts.maxHeight) {
131
- targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
132
- targetHeight = opts.maxHeight;
133
- }
64
+ // Check if already within all limits (dimensions AND size)
65
+ const originalSize = inputBuffer.length;
66
+ if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
67
+ return {
68
+ data: img.data,
69
+ mimeType: img.mimeType ?? `image/${format}`,
70
+ originalWidth,
71
+ originalHeight,
72
+ width: originalWidth,
73
+ height: originalHeight,
74
+ wasResized: false,
75
+ };
76
+ }
134
77
 
135
- // Helper to resize and encode in both formats, returning the smaller one
136
- function tryBothFormats(
137
- width: number,
138
- height: number,
139
- jpegQuality: number,
140
- ): { buffer: Uint8Array; mimeType: string } {
141
- const scale = Math.min(width / originalWidth, height / originalHeight);
142
- const resized = image!.resize(scale);
78
+ // Calculate initial dimensions respecting max limits
79
+ let targetWidth = originalWidth;
80
+ let targetHeight = originalHeight;
81
+
82
+ if (targetWidth > opts.maxWidth) {
83
+ targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
84
+ targetWidth = opts.maxWidth;
85
+ }
86
+ if (targetHeight > opts.maxHeight) {
87
+ targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
88
+ targetHeight = opts.maxHeight;
89
+ }
143
90
 
144
- const pngBuffer = resized.writeToBuffer(".png");
145
- const jpegBuffer = resized.writeToBuffer(".jpg", { Q: jpegQuality });
91
+ // Helper to resize and encode in both formats, returning the smaller one
92
+ function tryBothFormats(
93
+ width: number,
94
+ height: number,
95
+ jpegQuality: number,
96
+ ): { buffer: Uint8Array; mimeType: string } {
97
+ const resized = photon.resize(image!, width, height, photon.SamplingFilter.Lanczos3);
146
98
 
147
- resized.delete();
99
+ try {
100
+ const pngBuffer = resized.get_bytes();
101
+ const jpegBuffer = resized.get_bytes_jpeg(jpegQuality);
148
102
 
149
103
  return pickSmaller(
150
104
  { buffer: pngBuffer, mimeType: "image/png" },
151
105
  { buffer: jpegBuffer, mimeType: "image/jpeg" },
152
106
  );
107
+ } finally {
108
+ resized.free();
153
109
  }
110
+ }
111
+
112
+ // Try to produce an image under maxBytes
113
+ const qualitySteps = [85, 70, 55, 40];
114
+ const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
115
+
116
+ let best: { buffer: Uint8Array; mimeType: string };
117
+ let finalWidth = targetWidth;
118
+ let finalHeight = targetHeight;
154
119
 
155
- // Try to produce an image under maxBytes
156
- const qualitySteps = [85, 70, 55, 40];
157
- const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
120
+ // First attempt: resize to target dimensions, try both formats
121
+ best = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
158
122
 
159
- let best: { buffer: Uint8Array; mimeType: string };
160
- let finalWidth = targetWidth;
161
- let finalHeight = targetHeight;
123
+ if (best.buffer.length <= opts.maxBytes) {
124
+ return {
125
+ data: Buffer.from(best.buffer).toString("base64"),
126
+ mimeType: best.mimeType,
127
+ originalWidth,
128
+ originalHeight,
129
+ width: finalWidth,
130
+ height: finalHeight,
131
+ wasResized: true,
132
+ };
133
+ }
162
134
 
163
- // First attempt: resize to target dimensions, try both formats
164
- best = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
135
+ // Still too large - try JPEG with decreasing quality
136
+ for (const quality of qualitySteps) {
137
+ best = tryBothFormats(targetWidth, targetHeight, quality);
165
138
 
166
139
  if (best.buffer.length <= opts.maxBytes) {
167
140
  return {
@@ -174,10 +147,19 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
174
147
  wasResized: true,
175
148
  };
176
149
  }
150
+ }
151
+
152
+ // Still too large - reduce dimensions progressively
153
+ for (const scale of scaleSteps) {
154
+ finalWidth = Math.round(targetWidth * scale);
155
+ finalHeight = Math.round(targetHeight * scale);
156
+
157
+ if (finalWidth < 100 || finalHeight < 100) {
158
+ break;
159
+ }
177
160
 
178
- // Still too large - try JPEG with decreasing quality
179
161
  for (const quality of qualitySteps) {
180
- best = tryBothFormats(targetWidth, targetHeight, quality);
162
+ best = tryBothFormats(finalWidth, finalHeight, quality);
181
163
 
182
164
  if (best.buffer.length <= opts.maxBytes) {
183
165
  return {
@@ -191,51 +173,33 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
191
173
  };
192
174
  }
193
175
  }
176
+ }
194
177
 
195
- // Still too large - reduce dimensions progressively
196
- for (const scale of scaleSteps) {
197
- finalWidth = Math.round(targetWidth * scale);
198
- finalHeight = Math.round(targetHeight * scale);
199
-
200
- if (finalWidth < 100 || finalHeight < 100) {
201
- break;
202
- }
203
-
204
- for (const quality of qualitySteps) {
205
- best = tryBothFormats(finalWidth, finalHeight, quality);
206
-
207
- if (best.buffer.length <= opts.maxBytes) {
208
- return {
209
- data: Buffer.from(best.buffer).toString("base64"),
210
- mimeType: best.mimeType,
211
- originalWidth,
212
- originalHeight,
213
- width: finalWidth,
214
- height: finalHeight,
215
- wasResized: true,
216
- };
217
- }
218
- }
219
- }
220
-
221
- // Last resort: return smallest version we produced
222
- return {
223
- data: Buffer.from(best.buffer).toString("base64"),
224
- mimeType: best.mimeType,
225
- originalWidth,
226
- originalHeight,
227
- width: finalWidth,
228
- height: finalHeight,
229
- wasResized: true,
230
- };
231
- } finally {
232
- image.delete();
178
+ // Last resort: return smallest version we produced
179
+ return {
180
+ data: Buffer.from(best.buffer).toString("base64"),
181
+ mimeType: best.mimeType,
182
+ originalWidth,
183
+ originalHeight,
184
+ width: finalWidth,
185
+ height: finalHeight,
186
+ wasResized: true,
187
+ };
188
+ } catch {
189
+ // Failed to load image
190
+ return {
191
+ data: img.data,
192
+ mimeType: img.mimeType,
193
+ originalWidth: 0,
194
+ originalHeight: 0,
195
+ width: 0,
196
+ height: 0,
197
+ wasResized: false,
198
+ };
199
+ } finally {
200
+ if (image) {
201
+ image.free();
233
202
  }
234
- } catch (error) {
235
- logger.error("Failed to resize image with wasm-vips", {
236
- error: error instanceof Error ? error.message : String(error),
237
- });
238
- return resizeImageWithImageMagick(img, opts);
239
203
  }
240
204
  }
241
205
 
@@ -1,247 +0,0 @@
1
- let imagemagickCommand: string | null | undefined;
2
-
3
- /**
4
- * Detect available ImageMagick command.
5
- * Returns "magick" (IM7) or "convert" (IM6) or null if unavailable.
6
- */
7
- async function detectImageMagick(): Promise<string | null> {
8
- if (imagemagickCommand !== undefined) {
9
- return imagemagickCommand;
10
- }
11
-
12
- for (const cmd of ["magick", "convert"]) {
13
- try {
14
- const proc = Bun.spawn([cmd, "-version"], { stdout: "ignore", stderr: "ignore" });
15
- const code = await proc.exited;
16
- if (code === 0) {
17
- imagemagickCommand = cmd;
18
- return cmd;
19
- }
20
- } catch {}
21
- }
22
-
23
- imagemagickCommand = null;
24
- return null;
25
- }
26
-
27
- /**
28
- * Run ImageMagick command with buffer input/output.
29
- */
30
- async function runImageMagick(cmd: string, args: string[], input: Buffer): Promise<Buffer> {
31
- const proc = Bun.spawn([cmd, ...args], {
32
- stdin: new Blob([input]),
33
- stdout: "pipe",
34
- stderr: "pipe",
35
- });
36
-
37
- const [stdout, stderr, exitCode] = await Promise.all([
38
- new Response(proc.stdout).arrayBuffer(),
39
- new Response(proc.stderr).text(),
40
- proc.exited,
41
- ]);
42
-
43
- if (exitCode !== 0) {
44
- throw new Error(`ImageMagick exited with code ${exitCode}: ${stderr}`);
45
- }
46
-
47
- return Buffer.from(stdout);
48
- }
49
-
50
- /**
51
- * Convert image to PNG using ImageMagick.
52
- * Returns null if ImageMagick is unavailable or conversion fails.
53
- */
54
- export async function convertToPngWithImageMagick(
55
- base64Data: string,
56
- _mimeType: string,
57
- ): Promise<{ data: string; mimeType: string } | null> {
58
- const cmd = await detectImageMagick();
59
- if (!cmd) {
60
- return null;
61
- }
62
-
63
- try {
64
- const input = Buffer.from(base64Data, "base64");
65
- // "-" reads from stdin, "png:-" writes PNG to stdout
66
- const output = await runImageMagick(cmd, ["-", "png:-"], input);
67
- return {
68
- data: output.toString("base64"),
69
- mimeType: "image/png",
70
- };
71
- } catch {
72
- return null;
73
- }
74
- }
75
-
76
- export interface ImageMagickResizeResult {
77
- data: string; // base64
78
- mimeType: string;
79
- width: number;
80
- height: number;
81
- }
82
-
83
- /**
84
- * Get image dimensions using ImageMagick identify.
85
- */
86
- async function getImageDimensions(cmd: string, buffer: Buffer): Promise<{ width: number; height: number } | null> {
87
- try {
88
- // Use identify to get dimensions
89
- const identifyCmd = cmd === "magick" ? "magick" : "identify";
90
- const args = cmd === "magick" ? ["identify", "-format", "%w %h", "-"] : ["-format", "%w %h", "-"];
91
-
92
- const output = await runImageMagick(identifyCmd, args, buffer);
93
- const [w, h] = output.toString().trim().split(" ").map(Number);
94
- if (Number.isFinite(w) && Number.isFinite(h)) {
95
- return { width: w, height: h };
96
- }
97
- } catch {
98
- // Fall through
99
- }
100
- return null;
101
- }
102
-
103
- /**
104
- * Resize image using ImageMagick.
105
- * Returns null if ImageMagick is unavailable or operation fails.
106
- */
107
- export async function resizeWithImageMagick(
108
- base64Data: string,
109
- _mimeType: string,
110
- maxWidth: number,
111
- maxHeight: number,
112
- maxBytes: number,
113
- jpegQuality: number,
114
- ): Promise<ImageMagickResizeResult | null> {
115
- const cmd = await detectImageMagick();
116
- if (!cmd) {
117
- return null;
118
- }
119
-
120
- try {
121
- const input = Buffer.from(base64Data, "base64");
122
-
123
- // Get original dimensions
124
- const dims = await getImageDimensions(cmd, input);
125
- if (!dims) {
126
- return null;
127
- }
128
-
129
- // Check if already within limits
130
- if (dims.width <= maxWidth && dims.height <= maxHeight && input.length <= maxBytes) {
131
- return null; // Signal caller to use original
132
- }
133
-
134
- // Calculate target dimensions maintaining aspect ratio
135
- let targetWidth = dims.width;
136
- let targetHeight = dims.height;
137
-
138
- if (targetWidth > maxWidth) {
139
- targetHeight = Math.round((targetHeight * maxWidth) / targetWidth);
140
- targetWidth = maxWidth;
141
- }
142
- if (targetHeight > maxHeight) {
143
- targetWidth = Math.round((targetWidth * maxHeight) / targetHeight);
144
- targetHeight = maxHeight;
145
- }
146
-
147
- // Try PNG first, then JPEG with decreasing quality
148
- const attempts: Array<{ args: string[]; mimeType: string }> = [
149
- { args: ["-", "-resize", `${targetWidth}x${targetHeight}>`, "png:-"], mimeType: "image/png" },
150
- {
151
- args: ["-", "-resize", `${targetWidth}x${targetHeight}>`, "-quality", String(jpegQuality), "jpeg:-"],
152
- mimeType: "image/jpeg",
153
- },
154
- ];
155
-
156
- // Add lower quality JPEG attempts
157
- for (const q of [70, 55, 40]) {
158
- attempts.push({
159
- args: ["-", "-resize", `${targetWidth}x${targetHeight}>`, "-quality", String(q), "jpeg:-"],
160
- mimeType: "image/jpeg",
161
- });
162
- }
163
-
164
- let best: { buffer: Buffer; mimeType: string } | null = null;
165
-
166
- for (const attempt of attempts) {
167
- try {
168
- const output = await runImageMagick(cmd, attempt.args, input);
169
- if (output.length <= maxBytes) {
170
- return {
171
- data: output.toString("base64"),
172
- mimeType: attempt.mimeType,
173
- width: targetWidth,
174
- height: targetHeight,
175
- };
176
- }
177
- if (!best || output.length < best.buffer.length) {
178
- best = { buffer: output, mimeType: attempt.mimeType };
179
- }
180
- } catch {}
181
- }
182
-
183
- // Try progressively smaller dimensions
184
- const scaleSteps = [0.75, 0.5, 0.35, 0.25];
185
- for (const scale of scaleSteps) {
186
- const scaledWidth = Math.round(targetWidth * scale);
187
- const scaledHeight = Math.round(targetHeight * scale);
188
-
189
- if (scaledWidth < 100 || scaledHeight < 100) break;
190
-
191
- for (const q of [85, 70, 55, 40]) {
192
- try {
193
- const output = await runImageMagick(
194
- cmd,
195
- ["-", "-resize", `${scaledWidth}x${scaledHeight}>`, "-quality", String(q), "jpeg:-"],
196
- input,
197
- );
198
- if (output.length <= maxBytes) {
199
- return {
200
- data: output.toString("base64"),
201
- mimeType: "image/jpeg",
202
- width: scaledWidth,
203
- height: scaledHeight,
204
- };
205
- }
206
- if (!best || output.length < best.buffer.length) {
207
- best = { buffer: output, mimeType: "image/jpeg" };
208
- }
209
- } catch {}
210
- }
211
- }
212
-
213
- // Return best attempt even if over limit
214
- if (best) {
215
- return {
216
- data: best.buffer.toString("base64"),
217
- mimeType: best.mimeType,
218
- width: targetWidth,
219
- height: targetHeight,
220
- };
221
- }
222
-
223
- return null;
224
- } catch {
225
- return null;
226
- }
227
- }
228
-
229
- /**
230
- * Get image dimensions using ImageMagick.
231
- * Returns null if ImageMagick is unavailable.
232
- */
233
- export async function getImageDimensionsWithImageMagick(
234
- base64Data: string,
235
- ): Promise<{ width: number; height: number } | null> {
236
- const cmd = await detectImageMagick();
237
- if (!cmd) {
238
- return null;
239
- }
240
-
241
- try {
242
- const buffer = Buffer.from(base64Data, "base64");
243
- return await getImageDimensions(cmd, buffer);
244
- } catch {
245
- return null;
246
- }
247
- }
package/src/utils/vips.ts DELETED
@@ -1,23 +0,0 @@
1
- import type realVips from "wasm-vips";
2
- import { logger } from "../core/logger";
3
-
4
- // Cached vips instance
5
- let _vips: Promise<typeof realVips> | undefined;
6
-
7
- /**
8
- * Get the vips instance.
9
- * @returns The vips instance.
10
- */
11
- export function Vips(): Promise<typeof realVips> {
12
- if (_vips) return _vips;
13
-
14
- let instance: Promise<typeof realVips> | undefined;
15
- try {
16
- instance = import("wasm-vips").then((mod) => (mod.default ?? mod)());
17
- } catch (error) {
18
- logger.error("Failed to import wasm-vips", { error: error instanceof Error ? error.message : String(error) });
19
- instance = Promise.reject(error);
20
- }
21
- _vips = instance;
22
- return instance;
23
- }