@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 +23 -0
- package/package.json +6 -6
- package/src/cli/file-processor.ts +16 -7
- package/src/core/agent-storage.ts +1 -0
- package/src/core/history-storage.ts +1 -0
- package/src/core/model-registry.ts +1 -2
- package/src/core/model-resolver.ts +3 -0
- package/src/core/sdk.ts +1 -0
- package/src/core/session-manager.ts +4 -2
- package/src/core/settings-manager.ts +8 -0
- package/src/core/system-prompt.ts +4 -0
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/read.ts +20 -12
- package/src/core/tools/task/executor.ts +2 -0
- package/src/core/tools/task/index.ts +1 -0
- package/src/core/tools/task/worker-protocol.ts +2 -0
- package/src/core/tools/task/worker.ts +6 -0
- package/src/index.ts +2 -0
- package/src/modes/interactive/components/session-selector.ts +20 -7
- package/src/modes/interactive/components/tool-execution.ts +12 -8
- package/src/modes/interactive/theme/light.json +9 -9
- package/src/modes/interactive/utils/ui-helpers.ts +2 -0
- package/src/modes/print-mode.ts +8 -0
- package/src/utils/image-convert.ts +7 -16
- package/src/utils/image-resize.ts +107 -143
- package/src/utils/image-magick.ts +0 -247
- package/src/utils/vips.ts +0 -23
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.
|
|
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.
|
|
43
|
-
"@oh-my-pi/pi-ai": "5.
|
|
44
|
-
"@oh-my-pi/pi-git-tool": "5.
|
|
45
|
-
"@oh-my-pi/pi-tui": "5.
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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",
|
|
@@ -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
|
|
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
|
@@ -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
|
-
|
|
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,
|
package/src/core/tools/index.ts
CHANGED
|
@@ -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;
|
package/src/core/tools/read.ts
CHANGED
|
@@ -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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
};
|
|
@@ -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
|
-
|
|
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)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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": "#
|
|
6
|
-
"blue": "#
|
|
7
|
-
"green": "#
|
|
8
|
-
"red": "#
|
|
9
|
-
"yellow": "#
|
|
5
|
+
"teal": "#5a8080",
|
|
6
|
+
"blue": "#547da7",
|
|
7
|
+
"green": "#588458",
|
|
8
|
+
"red": "#aa5555",
|
|
9
|
+
"yellow": "#9a7326",
|
|
10
10
|
"mediumGray": "#6c6c6c",
|
|
11
|
-
"dimGray": "#
|
|
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": "#
|
|
72
|
-
"thinkingLow": "
|
|
73
|
-
"thinkingMedium": "
|
|
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
|
|
package/src/modes/print-mode.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
25
|
+
image.free();
|
|
30
26
|
}
|
|
31
|
-
} catch
|
|
32
|
-
//
|
|
33
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
60
|
+
const originalWidth = image.get_width();
|
|
61
|
+
const originalHeight = image.get_height();
|
|
62
|
+
const format = img.mimeType?.split("/")[1] ?? "png";
|
|
125
63
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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(
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
}
|