opencode-image-compressor 0.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/README.md +72 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +327 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Opencode Image Compressor plugin
|
|
2
|
+
|
|
3
|
+
`opencode-image-compressor` shrinks large image attachments into smaller JPEG payloads before they are passed into the model.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- checks supported images larger than `200 KB`
|
|
8
|
+
- rewrites successful outputs as `image/jpeg`
|
|
9
|
+
- aims for roughly `50 KB`
|
|
10
|
+
- accepts results up to `200 KB`
|
|
11
|
+
- supports `image/jpeg`, `image/jpg`, `image/pjpeg`, `image/png`, and `image/webp`
|
|
12
|
+
- does not process GIF input
|
|
13
|
+
- keeps the original file when recompression fails or would not reduce size
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Requirements:
|
|
18
|
+
|
|
19
|
+
- OpenCode plugin support with `@opencode-ai/plugin >= 1.2.27`
|
|
20
|
+
- a working `sharp` install in the runtime environment
|
|
21
|
+
|
|
22
|
+
This plugin is intended to work on macOS, Linux and Windows as long as `sharp` installs and loads correctly on that machine.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bun add opencode-image-compressor
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then add it to `opencode.json`:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"plugin": ["opencode-image-compressor"]
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If installing from source:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bun install
|
|
40
|
+
bun run build
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Development
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
bun install
|
|
47
|
+
bun run typecheck
|
|
48
|
+
bun run build
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Debug logging
|
|
52
|
+
|
|
53
|
+
Enable verbose logging:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
OPENCODE_IMAGE_COMPRESSOR_DEBUG=true
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Optional custom log file path:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
OPENCODE_IMAGE_COMPRESSOR_DEBUG_LOG_PATH=/tmp/opencode-image-compressor.log
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
When enabled, the plugin:
|
|
66
|
+
|
|
67
|
+
- appends trace lines to the log file for each inspected attachment
|
|
68
|
+
- emits runtime status lines to stderr
|
|
69
|
+
|
|
70
|
+
When debug is disabled, routine status messages stay silent and only actual errors are written to stderr.
|
|
71
|
+
|
|
72
|
+
On macOS, `/tmp` maps to `/private/tmp`, so check both locations if needed.
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
function envBool(name, fallback) {
|
|
3
|
+
const value = process.env[name]?.trim().toLowerCase();
|
|
4
|
+
if (!value) return fallback;
|
|
5
|
+
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
6
|
+
}
|
|
7
|
+
var DEFAULT_CONFIG = {
|
|
8
|
+
minBytesForCompression: 200 * 1024,
|
|
9
|
+
targetBytes: 50 * 1024,
|
|
10
|
+
maxOutputBytes: 200 * 1024,
|
|
11
|
+
debug: envBool("OPENCODE_IMAGE_COMPRESSOR_DEBUG", false),
|
|
12
|
+
debugLogPath: process.env.OPENCODE_IMAGE_COMPRESSOR_DEBUG_LOG_PATH || "/tmp/opencode-image-compressor.log",
|
|
13
|
+
background: { r: 255, g: 255, b: 255 },
|
|
14
|
+
supportedMimes: ["image/jpeg", "image/jpg", "image/pjpeg", "image/png", "image/webp"],
|
|
15
|
+
compressionPasses: [
|
|
16
|
+
{ maxEdge: null, quality: 55 },
|
|
17
|
+
{ maxEdge: null, quality: 45 },
|
|
18
|
+
{ maxEdge: 1600, quality: 40 },
|
|
19
|
+
{ maxEdge: 1280, quality: 35 },
|
|
20
|
+
{ maxEdge: 1024, quality: 32 },
|
|
21
|
+
{ maxEdge: 768, quality: 30 },
|
|
22
|
+
{ maxEdge: 640, quality: 28 }
|
|
23
|
+
],
|
|
24
|
+
cacheEntries: 128
|
|
25
|
+
};
|
|
26
|
+
function createConfig(overrides) {
|
|
27
|
+
return {
|
|
28
|
+
...DEFAULT_CONFIG,
|
|
29
|
+
...overrides,
|
|
30
|
+
debug: overrides?.debug ?? DEFAULT_CONFIG.debug,
|
|
31
|
+
debugLogPath: overrides?.debugLogPath ?? DEFAULT_CONFIG.debugLogPath,
|
|
32
|
+
background: overrides?.background ?? DEFAULT_CONFIG.background,
|
|
33
|
+
supportedMimes: overrides?.supportedMimes ?? DEFAULT_CONFIG.supportedMimes,
|
|
34
|
+
compressionPasses: overrides?.compressionPasses ?? DEFAULT_CONFIG.compressionPasses
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/compression/cache.ts
|
|
39
|
+
import { createHash } from "crypto";
|
|
40
|
+
var AttachmentCache = class {
|
|
41
|
+
constructor(maxEntries) {
|
|
42
|
+
this.maxEntries = maxEntries;
|
|
43
|
+
}
|
|
44
|
+
maxEntries;
|
|
45
|
+
entries = /* @__PURE__ */ new Map();
|
|
46
|
+
keyFor(buffer) {
|
|
47
|
+
return createHash("sha1").update(buffer).digest("hex");
|
|
48
|
+
}
|
|
49
|
+
get(key) {
|
|
50
|
+
const value = this.entries.get(key);
|
|
51
|
+
if (!value) return void 0;
|
|
52
|
+
this.entries.delete(key);
|
|
53
|
+
this.entries.set(key, value);
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
set(key, value) {
|
|
57
|
+
if (this.entries.has(key)) {
|
|
58
|
+
this.entries.delete(key);
|
|
59
|
+
}
|
|
60
|
+
this.entries.set(key, value);
|
|
61
|
+
if (this.entries.size > this.maxEntries) {
|
|
62
|
+
const oldest = this.entries.keys().next().value;
|
|
63
|
+
if (oldest) this.entries.delete(oldest);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// src/compression/compress.ts
|
|
69
|
+
import sharp from "sharp";
|
|
70
|
+
var LIMIT_INPUT_PIXELS = 16383 * 16383;
|
|
71
|
+
async function readMeta(buffer) {
|
|
72
|
+
const meta = await sharp(buffer, { limitInputPixels: LIMIT_INPUT_PIXELS }).metadata();
|
|
73
|
+
return {
|
|
74
|
+
width: meta.width ?? 0,
|
|
75
|
+
height: meta.height ?? 0
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function unchanged(buffer, reason, meta) {
|
|
79
|
+
return {
|
|
80
|
+
changed: false,
|
|
81
|
+
buffer,
|
|
82
|
+
originalBytes: buffer.length,
|
|
83
|
+
outputBytes: buffer.length,
|
|
84
|
+
width: meta?.width ?? 0,
|
|
85
|
+
height: meta?.height ?? 0,
|
|
86
|
+
quality: null,
|
|
87
|
+
maxEdge: null,
|
|
88
|
+
reason
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async function encodeCandidate(input, step, background) {
|
|
92
|
+
let pipeline = sharp(input, { limitInputPixels: LIMIT_INPUT_PIXELS }).rotate();
|
|
93
|
+
if (step.maxEdge) {
|
|
94
|
+
pipeline = pipeline.resize({
|
|
95
|
+
width: step.maxEdge,
|
|
96
|
+
height: step.maxEdge,
|
|
97
|
+
fit: "inside",
|
|
98
|
+
withoutEnlargement: true
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
pipeline = pipeline.flatten({ background }).jpeg({
|
|
102
|
+
quality: step.quality,
|
|
103
|
+
mozjpeg: true,
|
|
104
|
+
progressive: true
|
|
105
|
+
});
|
|
106
|
+
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
|
|
107
|
+
return { buffer: data, width: info.width, height: info.height };
|
|
108
|
+
}
|
|
109
|
+
async function compressAggressively(buffer, config = DEFAULT_CONFIG) {
|
|
110
|
+
if (buffer.length <= config.minBytesForCompression) {
|
|
111
|
+
return unchanged(buffer, "under threshold");
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const meta = await readMeta(buffer);
|
|
115
|
+
let best = null;
|
|
116
|
+
for (const step of config.compressionPasses) {
|
|
117
|
+
const candidate = await encodeCandidate(buffer, step, config.background);
|
|
118
|
+
const result = {
|
|
119
|
+
changed: true,
|
|
120
|
+
buffer: candidate.buffer,
|
|
121
|
+
originalBytes: buffer.length,
|
|
122
|
+
outputBytes: candidate.buffer.length,
|
|
123
|
+
width: candidate.width,
|
|
124
|
+
height: candidate.height,
|
|
125
|
+
quality: step.quality,
|
|
126
|
+
maxEdge: step.maxEdge,
|
|
127
|
+
reason: "optimized"
|
|
128
|
+
};
|
|
129
|
+
if (result.outputBytes >= result.originalBytes) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (result.outputBytes <= config.targetBytes) {
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
if (result.outputBytes <= config.maxOutputBytes) {
|
|
136
|
+
if (!best || result.outputBytes < best.outputBytes) {
|
|
137
|
+
best = result;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return best ?? unchanged(buffer, "could not reach output policy", meta);
|
|
142
|
+
} catch {
|
|
143
|
+
return unchanged(buffer, "compression failed");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/logger.ts
|
|
148
|
+
import fs from "fs/promises";
|
|
149
|
+
var PREFIX = "[image-compressor]";
|
|
150
|
+
function formatBytes(bytes) {
|
|
151
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
152
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
153
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
154
|
+
}
|
|
155
|
+
function logConsole(config, message) {
|
|
156
|
+
if (!config.debug) return;
|
|
157
|
+
console.error(`${PREFIX} ${message}`);
|
|
158
|
+
}
|
|
159
|
+
function logReady(config) {
|
|
160
|
+
logConsole(config, "Ready to recompress large image attachments");
|
|
161
|
+
}
|
|
162
|
+
function logCompressed(config, result) {
|
|
163
|
+
logConsole(
|
|
164
|
+
config,
|
|
165
|
+
`Compressed ${formatBytes(result.originalBytes)} \u2192 ${formatBytes(result.outputBytes)} (jpeg, ${result.width}x${result.height}, q=${result.quality ?? "-"}, edge=${result.maxEdge ?? "orig"})`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
function logBypassed(config, reason) {
|
|
169
|
+
logConsole(config, `Bypassed attachment: ${reason}`);
|
|
170
|
+
}
|
|
171
|
+
function logError(error) {
|
|
172
|
+
console.error(`${PREFIX} Error: ${error.message}`);
|
|
173
|
+
}
|
|
174
|
+
async function logDebug(config, message) {
|
|
175
|
+
if (!config.debug) return;
|
|
176
|
+
const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${message}
|
|
177
|
+
`;
|
|
178
|
+
try {
|
|
179
|
+
await fs.appendFile(config.debugLogPath, line, "utf8");
|
|
180
|
+
} catch {
|
|
181
|
+
console.error(`${PREFIX} Failed writing debug log: ${config.debugLogPath}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/attachments/resolve.ts
|
|
186
|
+
import fs2 from "fs/promises";
|
|
187
|
+
import { fileURLToPath } from "url";
|
|
188
|
+
var DATA_URI_PREFIX = "data:";
|
|
189
|
+
var BASE64_MARKER = ";base64,";
|
|
190
|
+
function isSupportedMime(mime, config) {
|
|
191
|
+
return config.supportedMimes.includes(mime);
|
|
192
|
+
}
|
|
193
|
+
function parseDataUri(url, config) {
|
|
194
|
+
if (!url.startsWith(DATA_URI_PREFIX)) return null;
|
|
195
|
+
const markerIndex = url.indexOf(BASE64_MARKER);
|
|
196
|
+
if (markerIndex === -1) return null;
|
|
197
|
+
const mime = url.slice(DATA_URI_PREFIX.length, markerIndex);
|
|
198
|
+
if (!isSupportedMime(mime, config)) return null;
|
|
199
|
+
const base64 = url.slice(markerIndex + BASE64_MARKER.length);
|
|
200
|
+
if (!base64) return null;
|
|
201
|
+
return Buffer.from(base64, "base64");
|
|
202
|
+
}
|
|
203
|
+
async function resolveImageBuffer(url, mime, config) {
|
|
204
|
+
if (!isSupportedMime(mime, config)) return null;
|
|
205
|
+
const parsed = parseDataUri(url, config);
|
|
206
|
+
if (parsed) return parsed;
|
|
207
|
+
try {
|
|
208
|
+
if (url.startsWith("file://")) {
|
|
209
|
+
return await fs2.readFile(fileURLToPath(url));
|
|
210
|
+
}
|
|
211
|
+
if (url.startsWith("/")) {
|
|
212
|
+
return await fs2.readFile(url);
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
function toJpegDataUri(buffer) {
|
|
220
|
+
return `data:image/jpeg;base64,${buffer.toString("base64")}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/attachments/rewriter.ts
|
|
224
|
+
function createAttachmentRewriter(config) {
|
|
225
|
+
const cache = new AttachmentCache(config.cacheEntries);
|
|
226
|
+
return async function rewriteAttachment(attachment) {
|
|
227
|
+
await logDebug(config, `inspect attachment mime=${attachment.mime} urlPrefix=${attachment.url.slice(0, 48)}`);
|
|
228
|
+
const sourceBuffer = await resolveImageBuffer(attachment.url, attachment.mime, config);
|
|
229
|
+
if (!sourceBuffer) {
|
|
230
|
+
await logDebug(config, `ignore attachment reason=unresolved-or-unsupported mime=${attachment.mime}`);
|
|
231
|
+
logBypassed(config, "unsupported or unreadable image payload");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const key = cache.keyFor(sourceBuffer);
|
|
235
|
+
const cached = cache.get(key);
|
|
236
|
+
await logDebug(
|
|
237
|
+
config,
|
|
238
|
+
`resolved bytes=${sourceBuffer.length} cache=${cached ? "hit" : "miss"} mime=${attachment.mime}`
|
|
239
|
+
);
|
|
240
|
+
const result = cached ?? await compressAggressively(sourceBuffer, config);
|
|
241
|
+
if (!cached) cache.set(key, result);
|
|
242
|
+
if (!result.changed) {
|
|
243
|
+
await logDebug(config, `leave-original reason=${result.reason} bytes=${result.outputBytes}`);
|
|
244
|
+
logBypassed(config, result.reason);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
attachment.url = toJpegDataUri(result.buffer);
|
|
249
|
+
attachment.mime = "image/jpeg";
|
|
250
|
+
await logDebug(
|
|
251
|
+
config,
|
|
252
|
+
`rewrite attachment original=${result.originalBytes} output=${result.outputBytes} q=${result.quality ?? "-"} edge=${result.maxEdge ?? "orig"}`
|
|
253
|
+
);
|
|
254
|
+
logCompressed(config, result);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
await logDebug(config, `error applying optimized payload: ${String(error)}`);
|
|
257
|
+
logError(error instanceof Error ? error : new Error(String(error)));
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// src/hooks/run-hook-safely.ts
|
|
263
|
+
async function runHookSafely(task) {
|
|
264
|
+
try {
|
|
265
|
+
await task();
|
|
266
|
+
} catch (error) {
|
|
267
|
+
logError(error instanceof Error ? error : new Error(String(error)));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/hooks/tool-hook.ts
|
|
272
|
+
function createToolHook(config) {
|
|
273
|
+
const rewriteAttachment = createAttachmentRewriter(config);
|
|
274
|
+
return async (input, output) => {
|
|
275
|
+
await runHookSafely(async () => {
|
|
276
|
+
if (input.tool !== "read") return;
|
|
277
|
+
const attachments = output.attachments;
|
|
278
|
+
if (!attachments?.length) return;
|
|
279
|
+
for (const attachment of attachments) {
|
|
280
|
+
if (attachment.type !== "file") continue;
|
|
281
|
+
await rewriteAttachment(attachment);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/hooks/message-hook.ts
|
|
288
|
+
function isDirectAttachment(part) {
|
|
289
|
+
return typeof part.mime === "string" && typeof part.url === "string";
|
|
290
|
+
}
|
|
291
|
+
function createMessageHook(config) {
|
|
292
|
+
const rewriteAttachment = createAttachmentRewriter(config);
|
|
293
|
+
return async (_input, output) => {
|
|
294
|
+
await runHookSafely(async () => {
|
|
295
|
+
for (const message of output.messages) {
|
|
296
|
+
for (const part of message.parts) {
|
|
297
|
+
if (part.type === "tool") {
|
|
298
|
+
if (part.state?.status !== "completed" || !part.state.attachments?.length) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
for (const attachment of part.state.attachments) {
|
|
302
|
+
await rewriteAttachment(attachment);
|
|
303
|
+
}
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (isDirectAttachment(part)) {
|
|
307
|
+
await rewriteAttachment(part);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/index.ts
|
|
316
|
+
var imageCompressor = async () => {
|
|
317
|
+
const config = createConfig();
|
|
318
|
+
logReady(config);
|
|
319
|
+
return {
|
|
320
|
+
"tool.execute.after": createToolHook(config),
|
|
321
|
+
"experimental.chat.messages.transform": createMessageHook(config)
|
|
322
|
+
};
|
|
323
|
+
};
|
|
324
|
+
var index_default = imageCompressor;
|
|
325
|
+
export {
|
|
326
|
+
index_default as default
|
|
327
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-image-compressor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin that recompresses large image attachments into model-friendly JPEG payloads",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"opencode",
|
|
15
|
+
"plugin",
|
|
16
|
+
"image",
|
|
17
|
+
"jpeg",
|
|
18
|
+
"compression",
|
|
19
|
+
"attachments",
|
|
20
|
+
"images"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"sharp": "^0.33.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@opencode-ai/plugin": ">=1.2.27"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@opencode-ai/plugin": "^1.2.27",
|
|
31
|
+
"@types/bun": "latest",
|
|
32
|
+
"tsup": "^8.0.0",
|
|
33
|
+
"typescript": "^5.0.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup",
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"prepublishOnly": "bun run typecheck && bun run build"
|
|
39
|
+
}
|
|
40
|
+
}
|