@sylphx/image-reader-mcp 0.1.3 → 0.1.7

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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ### Image evidence for agents
6
6
 
7
- **Iris** (transitional package `@sylphx/image-reader-mcp`) — local-first image facts, not default VLM guesses.
7
+ **Iris** (transitional package `@sylphx/image-reader-mcp`) — **Rust-first** local image facts, not default VLM guesses. `sharp`/`exifr` are optional, not required for MCP.
8
8
 
9
9
  <p align="center">
10
10
  <img src="https://mark.sylphx.com/api/v1/banner?type=holo&theme=tokyonight&text=image+reader+mcp&desc=Evidence-first+image+reading+for+AI+agents+%E2%80%94+metadata%2C+OCR+text%2C+regions%2C+and+ci&height=200&animation=rise&credit=0" alt="image-reader-mcp — Sylphx Mark banner" width="100%" />
@@ -78,7 +78,7 @@ an image, not a creative caption.**
78
78
  | Paraphrased OCR | Optional Tesseract lines with bounding boxes and confidence |
79
79
  | GPS and EXIF leak into context | GPS redacted; trust warnings for suspicious metadata |
80
80
  | No provenance | Agent Media Twin JSON with measurable, citeable fields |
81
- | Cloud API by default | **Local-first** — sharp + exifr on your machine |
81
+ | Cloud API by default | **Local-first** — Rust decode + optional Tesseract OCR; sharp/exifr only optional fallbacks |
82
82
  | Ship and pray | **23** unit tests on schema, metadata, OCR hooks, safety limits, doctor, and release gate |
83
83
 
84
84
  ## See it work
@@ -141,7 +141,7 @@ Abbreviated shape — optional OCR skips gracefully when Tesseract is not instal
141
141
  | --- | --- |
142
142
  | `read_image` | Read a local image and return dimensions, mime, metadata, optional OCR, and trust warnings. |
143
143
 
144
- Supported formats: PNG, JPEG, GIF, WebP, TIFF, and other formats sharp can decode.
144
+ Supported formats: PNG, JPEG, GIF, WebP, TIFF, and other formats the **Rust decode engine** supports (optional sharp covers additional formats when installed).
145
145
 
146
146
  ## Quick Start
147
147
 
@@ -1,25 +1,96 @@
1
1
  #!/usr/bin/env bash
2
- # Image Reader MCP launcher — Rust rmcp only (fail-closed, no TS stdio fallback).
2
+ # Iris MCP launcher — Rust rmcp only (fail-closed).
3
+ # Resolves native via optionalDependencies, staged bin/native, or cargo target.
3
4
  set -euo pipefail
4
5
 
5
- ROOT="$(cd "$(dirname "$0")/.." && pwd)"
6
+ PACKAGE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
7
+
8
+ host_platform_key() {
9
+ local os arch
10
+ os="$(uname -s 2>/dev/null || echo unknown)"
11
+ arch="$(uname -m 2>/dev/null || echo unknown)"
12
+ case "$os" in
13
+ Darwin) os="darwin" ;;
14
+ Linux) os="linux" ;;
15
+ *) os="$(printf '%s' "$os" | tr '[:upper:]' '[:lower:]')" ;;
16
+ esac
17
+ case "$arch" in
18
+ x86_64|amd64) arch="x64" ;;
19
+ aarch64|arm64) arch="arm64" ;;
20
+ esac
21
+ printf '%s-%s\n' "$os" "$arch"
22
+ }
23
+
24
+ platform_package_name() {
25
+ case "$(host_platform_key)" in
26
+ darwin-arm64) printf '%s\n' '@sylphx/image-reader-mcp-darwin-arm64' ;;
27
+ darwin-x64) printf '%s\n' '@sylphx/image-reader-mcp-darwin-x64' ;;
28
+ linux-x64) printf '%s\n' '@sylphx/image-reader-mcp-linux-x64-gnu' ;;
29
+ linux-arm64) printf '%s\n' '@sylphx/image-reader-mcp-linux-arm64-gnu' ;;
30
+ *) return 1 ;;
31
+ esac
32
+ }
33
+
34
+ is_runnable_native() {
35
+ local candidate="$1"
36
+ [[ -n "$candidate" && -f "$candidate" && -x "$candidate" ]] || return 1
37
+ return 0
38
+ }
39
+
40
+ resolve_from_optional_dep() {
41
+ local pkg_name candidate
42
+ pkg_name="$(platform_package_name 2>/dev/null || true)"
43
+ [[ -n "$pkg_name" ]] || return 1
44
+ local search_roots=(
45
+ "$PACKAGE_ROOT/node_modules"
46
+ "$PACKAGE_ROOT/../node_modules"
47
+ "$PACKAGE_ROOT/../../node_modules"
48
+ )
49
+ for root in "${search_roots[@]}"; do
50
+ candidate="$root/$pkg_name/image-reader-mcp-server"
51
+ if is_runnable_native "$candidate"; then
52
+ printf '%s\n' "$candidate"
53
+ return 0
54
+ fi
55
+ done
56
+ if command -v node >/dev/null 2>&1; then
57
+ candidate="$(
58
+ node -e "
59
+ try {
60
+ const p = require('node:path');
61
+ const { createRequire } = require('node:module');
62
+ const r = createRequire(p.join(process.argv[1], 'package.json'));
63
+ const pkg = r.resolve(process.argv[2] + '/package.json');
64
+ process.stdout.write(p.join(p.dirname(pkg), 'image-reader-mcp-server'));
65
+ } catch { process.exit(1); }
66
+ " "$PACKAGE_ROOT" "$pkg_name" 2>/dev/null || true
67
+ )"
68
+ if is_runnable_native "${candidate:-}"; then
69
+ printf '%s\n' "$candidate"
70
+ return 0
71
+ fi
72
+ fi
73
+ return 1
74
+ }
6
75
 
7
76
  resolve_rust_bin() {
8
77
  if [[ -n "${IMAGE_READER_MCP_RUST_BIN:-}" && -x "${IMAGE_READER_MCP_RUST_BIN}" ]]; then
9
78
  printf '%s\n' "${IMAGE_READER_MCP_RUST_BIN}"
10
79
  return 0
11
80
  fi
12
-
81
+ if bin="$(resolve_from_optional_dep)"; then
82
+ printf '%s\n' "$bin"
83
+ return 0
84
+ fi
13
85
  for candidate in \
14
- "$ROOT/bin/native/image-reader-mcp-server" \
15
- "$ROOT/target/release/image-reader-mcp-server" \
16
- "$ROOT/target/debug/image-reader-mcp-server"; do
17
- if [[ -x "$candidate" ]]; then
86
+ "$PACKAGE_ROOT/bin/native/image-reader-mcp-server" \
87
+ "$PACKAGE_ROOT/target/release/image-reader-mcp-server" \
88
+ "$PACKAGE_ROOT/target/debug/image-reader-mcp-server"; do
89
+ if is_runnable_native "$candidate"; then
18
90
  printf '%s\n' "$candidate"
19
91
  return 0
20
92
  fi
21
93
  done
22
-
23
94
  return 1
24
95
  }
25
96
 
@@ -44,5 +115,5 @@ if bin="$(resolve_rust_bin)"; then
44
115
  exec "$bin" "$@"
45
116
  fi
46
117
 
47
- echo "[image-reader-mcp] Rust MCP server (rmcp) is not built. Run: bun run build:rust" >&2
118
+ echo "[iris] Rust MCP server not found. Install matching optional native package (@sylphx/image-reader-mcp-<platform>) or run: bun run build:rust" >&2
48
119
  exit 1
@@ -118,15 +118,15 @@ var require_filesystem = __commonJS((exports, module) => {
118
118
  var LDD_PATH = "/usr/bin/ldd";
119
119
  var SELF_PATH = "/proc/self/exe";
120
120
  var MAX_LENGTH = 2048;
121
- var readFileSync = (path) => {
122
- const fd = fs.openSync(path, "r");
121
+ var readFileSync = (path2) => {
122
+ const fd = fs.openSync(path2, "r");
123
123
  const buffer = Buffer.alloc(MAX_LENGTH);
124
124
  const bytesRead = fs.readSync(fd, buffer, 0, MAX_LENGTH, 0);
125
125
  fs.close(fd, () => {});
126
126
  return buffer.subarray(0, bytesRead);
127
127
  };
128
- var readFile = (path) => new Promise((resolve, reject) => {
129
- fs.open(path, "r", (err, fd) => {
128
+ var readFile = (path2) => new Promise((resolve, reject) => {
129
+ fs.open(path2, "r", (err, fd) => {
130
130
  if (err) {
131
131
  reject(err);
132
132
  } else {
@@ -238,11 +238,11 @@ var require_detect_libc = __commonJS((exports, module) => {
238
238
  }
239
239
  return null;
240
240
  };
241
- var familyFromInterpreterPath = (path) => {
242
- if (path) {
243
- if (path.includes("/ld-musl-")) {
241
+ var familyFromInterpreterPath = (path2) => {
242
+ if (path2) {
243
+ if (path2.includes("/ld-musl-")) {
244
244
  return MUSL;
245
- } else if (path.includes("/ld-linux-")) {
245
+ } else if (path2.includes("/ld-linux-")) {
246
246
  return GLIBC;
247
247
  }
248
248
  }
@@ -287,8 +287,8 @@ var require_detect_libc = __commonJS((exports, module) => {
287
287
  cachedFamilyInterpreter = null;
288
288
  try {
289
289
  const selfContent = await readFile(SELF_PATH);
290
- const path = interpreterPath(selfContent);
291
- cachedFamilyInterpreter = familyFromInterpreterPath(path);
290
+ const path2 = interpreterPath(selfContent);
291
+ cachedFamilyInterpreter = familyFromInterpreterPath(path2);
292
292
  } catch (e) {}
293
293
  return cachedFamilyInterpreter;
294
294
  };
@@ -299,8 +299,8 @@ var require_detect_libc = __commonJS((exports, module) => {
299
299
  cachedFamilyInterpreter = null;
300
300
  try {
301
301
  const selfContent = readFileSync(SELF_PATH);
302
- const path = interpreterPath(selfContent);
303
- cachedFamilyInterpreter = familyFromInterpreterPath(path);
302
+ const path2 = interpreterPath(selfContent);
303
+ cachedFamilyInterpreter = familyFromInterpreterPath(path2);
304
304
  } catch (e) {}
305
305
  return cachedFamilyInterpreter;
306
306
  };
@@ -1777,7 +1777,7 @@ var require_libvips = __commonJS((exports, module) => {
1777
1777
  Copyright 2013 Lovell Fuller and others.
1778
1778
  SPDX-License-Identifier: Apache-2.0
1779
1779
  */
1780
- var { spawnSync } = __require("node:child_process");
1780
+ var { spawnSync: spawnSync2 } = __require("node:child_process");
1781
1781
  var { createHash } = __require("node:crypto");
1782
1782
  var semverCoerce = require_coerce();
1783
1783
  var semverGreaterThanOrEqualTo = require_gte();
@@ -1861,7 +1861,7 @@ var require_libvips = __commonJS((exports, module) => {
1861
1861
  };
1862
1862
  var isRosetta = () => {
1863
1863
  if (process.platform === "darwin" && process.arch === "x64") {
1864
- const translated = spawnSync("sysctl sysctl.proc_translated", spawnSyncOptions).stdout;
1864
+ const translated = spawnSync2("sysctl sysctl.proc_translated", spawnSyncOptions).stdout;
1865
1865
  return (translated || "").trim() === "sysctl.proc_translated: 1";
1866
1866
  }
1867
1867
  return false;
@@ -1877,13 +1877,13 @@ var require_libvips = __commonJS((exports, module) => {
1877
1877
  } catch {}
1878
1878
  return "";
1879
1879
  };
1880
- var spawnRebuild = () => spawnSync(`node-gyp rebuild --directory=src ${isEmscripten() ? "--nodedir=emscripten" : ""}`, {
1880
+ var spawnRebuild = () => spawnSync2(`node-gyp rebuild --directory=src ${isEmscripten() ? "--nodedir=emscripten" : ""}`, {
1881
1881
  ...spawnSyncOptions,
1882
1882
  stdio: "inherit"
1883
1883
  }).status;
1884
1884
  var globalLibvipsVersion = () => {
1885
1885
  if (process.platform !== "win32") {
1886
- const globalLibvipsVersion2 = spawnSync("pkg-config --modversion vips-cpp", {
1886
+ const globalLibvipsVersion2 = spawnSync2("pkg-config --modversion vips-cpp", {
1887
1887
  ...spawnSyncOptions,
1888
1888
  env: {
1889
1889
  ...process.env,
@@ -1897,7 +1897,7 @@ var require_libvips = __commonJS((exports, module) => {
1897
1897
  };
1898
1898
  var pkgConfigPath = () => {
1899
1899
  if (process.platform !== "win32") {
1900
- const brewPkgConfigPath = spawnSync('which brew >/dev/null 2>&1 && brew environment --plain | grep PKG_CONFIG_LIBDIR | cut -d" " -f2', spawnSyncOptions).stdout || "";
1900
+ const brewPkgConfigPath = spawnSync2('which brew >/dev/null 2>&1 && brew environment --plain | grep PKG_CONFIG_LIBDIR | cut -d" " -f2', spawnSyncOptions).stdout || "";
1901
1901
  return [
1902
1902
  brewPkgConfigPath.trim(),
1903
1903
  process.env.PKG_CONFIG_PATH,
@@ -1962,18 +1962,18 @@ var require_sharp = __commonJS((exports, module) => {
1962
1962
  `@img/sharp-${runtimePlatform}/sharp.node`,
1963
1963
  "@img/sharp-wasm32/sharp.node"
1964
1964
  ];
1965
- var path;
1965
+ var path2;
1966
1966
  var sharp;
1967
1967
  var errors = [];
1968
- for (path of paths) {
1968
+ for (path2 of paths) {
1969
1969
  try {
1970
- sharp = __require(path);
1970
+ sharp = __require(path2);
1971
1971
  break;
1972
1972
  } catch (err) {
1973
1973
  errors.push(err);
1974
1974
  }
1975
1975
  }
1976
- if (sharp && path.startsWith("@img/sharp-linux-x64") && !sharp._isUsingX64V2()) {
1976
+ if (sharp && path2.startsWith("@img/sharp-linux-x64") && !sharp._isUsingX64V2()) {
1977
1977
  const err = new Error("Prebuilt binaries for linux-x64 require v2 microarchitecture");
1978
1978
  err.code = "Unsupported CPU";
1979
1979
  errors.push(err);
@@ -4835,15 +4835,15 @@ var require_color = __commonJS((exports, module) => {
4835
4835
  };
4836
4836
  }
4837
4837
  function wrapConversion(toModel, graph) {
4838
- const path = [graph[toModel].parent, toModel];
4838
+ const path2 = [graph[toModel].parent, toModel];
4839
4839
  let fn = conversions_default[graph[toModel].parent][toModel];
4840
4840
  let cur = graph[toModel].parent;
4841
4841
  while (graph[cur].parent) {
4842
- path.unshift(graph[cur].parent);
4842
+ path2.unshift(graph[cur].parent);
4843
4843
  fn = link(conversions_default[graph[cur].parent][cur], fn);
4844
4844
  cur = graph[cur].parent;
4845
4845
  }
4846
- fn.conversion = path;
4846
+ fn.conversion = path2;
4847
4847
  return fn;
4848
4848
  }
4849
4849
  function route(fromModel) {
@@ -5448,7 +5448,7 @@ var require_output = __commonJS((exports, module) => {
5448
5448
  Copyright 2013 Lovell Fuller and others.
5449
5449
  SPDX-License-Identifier: Apache-2.0
5450
5450
  */
5451
- var path = __require("node:path");
5451
+ var path2 = __require("node:path");
5452
5452
  var is = require_is();
5453
5453
  var sharp = require_sharp();
5454
5454
  var formats = new Map([
@@ -5479,9 +5479,9 @@ var require_output = __commonJS((exports, module) => {
5479
5479
  let err;
5480
5480
  if (!is.string(fileOut)) {
5481
5481
  err = new Error("Missing output file path");
5482
- } else if (is.string(this.options.input.file) && path.resolve(this.options.input.file) === path.resolve(fileOut)) {
5482
+ } else if (is.string(this.options.input.file) && path2.resolve(this.options.input.file) === path2.resolve(fileOut)) {
5483
5483
  err = new Error("Cannot use same file for input and output");
5484
- } else if (jp2Regex.test(path.extname(fileOut)) && !this.constructor.format.jp2k.output.file) {
5484
+ } else if (jp2Regex.test(path2.extname(fileOut)) && !this.constructor.format.jp2k.output.file) {
5485
5485
  err = errJp2Save();
5486
5486
  }
5487
5487
  if (err) {
@@ -6453,7 +6453,6 @@ var require_lib = __commonJS((exports, module) => {
6453
6453
  import { createRequire as createRequire2 } from "node:module";
6454
6454
 
6455
6455
  // src/doctor.ts
6456
- var import_sharp = __toESM(require_lib(), 1);
6457
6456
  import { spawnSync as spawnSync2 } from "node:child_process";
6458
6457
  import { existsSync as existsSync2 } from "node:fs";
6459
6458
  import path2 from "node:path";
@@ -6549,7 +6548,18 @@ var probeTesseract = () => {
6549
6548
  };
6550
6549
  var probeSharp = async () => {
6551
6550
  try {
6552
- const buffer = await import_sharp.default({
6551
+ let sharpMod;
6552
+ try {
6553
+ sharpMod = (await Promise.resolve().then(() => __toESM(require_lib(), 1))).default ?? await Promise.resolve().then(() => __toESM(require_lib(), 1));
6554
+ } catch {
6555
+ return {
6556
+ id: "sharp",
6557
+ status: "warn",
6558
+ message: "sharp not installed (optional). Rust decode path is preferred for MCP."
6559
+ };
6560
+ }
6561
+ const sharp = sharpMod;
6562
+ const buffer = await sharp({
6553
6563
  create: {
6554
6564
  width: 1,
6555
6565
  height: 1,
@@ -6567,7 +6577,7 @@ var probeSharp = async () => {
6567
6577
  return {
6568
6578
  id: "sharp",
6569
6579
  status: "ok",
6570
- message: `sharp decode pipeline is available (v${import_sharp.default.versions.sharp}).`
6580
+ message: `sharp decode pipeline is available (v${sharp.versions?.sharp ?? "unknown"}).`
6571
6581
  };
6572
6582
  } catch (error) {
6573
6583
  const message = error instanceof Error ? error.message : String(error);
@@ -6642,7 +6652,7 @@ var probeRustDecodeFlag = () => {
6642
6652
  return {
6643
6653
  id: "rust_decode_flag",
6644
6654
  status: "warn",
6645
- message: "Rust decode CLI is not built. sharp remains the default probe path until `cargo build --release`."
6655
+ message: "Rust decode CLI is not built. Install/stage image-reader-cli for native decode, or optional `sharp` for TS fallback."
6646
6656
  };
6647
6657
  };
6648
6658
  var probeNode = () => {
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@sylphx/image-reader-mcp",
3
- "version": "0.1.3",
3
+ "version": "0.1.7",
4
4
  "mcpName": "io.github.SylphxAI/image-reader-mcp",
5
- "description": "Iris \u2014 Evidence-first image reading for AI agents \u2014 metadata, OCR text, regions, and citeable evidence without generative LLM.",
5
+ "description": "Iris Evidence-first image reading for AI agents metadata, OCR text, regions, and citeable evidence without generative LLM.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "image-reader-mcp": "./bin/image-reader-mcp",
9
9
  "iris": "./bin/image-reader-mcp"
10
10
  },
11
11
  "files": [
12
- "bin/",
12
+ "bin/image-reader-mcp",
13
13
  "dist/",
14
14
  "README.md",
15
15
  "LICENSE",
@@ -69,8 +69,6 @@
69
69
  },
70
70
  "dependencies": {
71
71
  "@modelcontextprotocol/sdk": "^1.29.0",
72
- "exifr": "^7.1.3",
73
- "sharp": "^0.34.3",
74
72
  "zod": "^4.4.3"
75
73
  },
76
74
  "devDependencies": {
@@ -82,5 +80,13 @@
82
80
  "typescript": "^7.0.2"
83
81
  },
84
82
  "packageManager": "bun@1.3.12",
85
- "private": false
83
+ "private": false,
84
+ "optionalDependencies": {
85
+ "sharp": "^0.34.3",
86
+ "exifr": "^7.1.3",
87
+ "@sylphx/image-reader-mcp-darwin-arm64": "0.1.7",
88
+ "@sylphx/image-reader-mcp-darwin-x64": "0.1.7",
89
+ "@sylphx/image-reader-mcp-linux-x64-gnu": "0.1.7",
90
+ "@sylphx/image-reader-mcp-linux-arm64-gnu": "0.1.7"
91
+ }
86
92
  }
package/src/doctor.ts CHANGED
@@ -2,7 +2,6 @@ import { spawnSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import sharp from 'sharp';
6
5
  import { resolveRustCliBinary } from './engine/rust-decode.js';
7
6
  import { listTesseractLanguages } from './utils/ocr.js';
8
7
  import { IMAGE_SAFETY_LIMITS } from './utils/safety.js';
@@ -53,6 +52,26 @@ const probeTesseract = (): DoctorCheck => {
53
52
 
54
53
  const probeSharp = async (): Promise<DoctorCheck> => {
55
54
  try {
55
+ let sharpMod: unknown;
56
+ try {
57
+ sharpMod =
58
+ ((await import('sharp')) as { default?: unknown }).default ?? (await import('sharp'));
59
+ } catch {
60
+ return {
61
+ id: 'sharp',
62
+ status: 'warn',
63
+ message: 'sharp not installed (optional). Rust decode path is preferred for MCP.',
64
+ };
65
+ }
66
+ const sharp = sharpMod as {
67
+ (
68
+ input: unknown,
69
+ opts?: unknown
70
+ ): {
71
+ png: () => { toBuffer: () => Promise<Buffer> };
72
+ };
73
+ versions?: { sharp?: string };
74
+ };
56
75
  const buffer = await sharp({
57
76
  create: {
58
77
  width: 1,
@@ -75,7 +94,7 @@ const probeSharp = async (): Promise<DoctorCheck> => {
75
94
  return {
76
95
  id: 'sharp',
77
96
  status: 'ok',
78
- message: `sharp decode pipeline is available (v${sharp.versions.sharp}).`,
97
+ message: `sharp decode pipeline is available (v${sharp.versions?.sharp ?? 'unknown'}).`,
79
98
  };
80
99
  } catch (error: unknown) {
81
100
  const message = error instanceof Error ? error.message : String(error);
@@ -162,7 +181,7 @@ const probeRustDecodeFlag = (): DoctorCheck => {
162
181
  id: 'rust_decode_flag',
163
182
  status: 'warn',
164
183
  message:
165
- 'Rust decode CLI is not built. sharp remains the default probe path until `cargo build --release`.',
184
+ 'Rust decode CLI is not built. Install/stage image-reader-cli for native decode, or optional `sharp` for TS fallback.',
166
185
  };
167
186
  };
168
187
 
@@ -1,7 +1,5 @@
1
1
  import fs, { stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import exifr from 'exifr';
4
- import sharp from 'sharp';
5
3
  import {
6
4
  cropRegionViaRustEngine,
7
5
  probeImageViaRustEngine,
@@ -46,6 +44,23 @@ const readMetadata = async (
46
44
  }
47
45
 
48
46
  try {
47
+ let exifr: {
48
+ parse: (
49
+ path: string,
50
+ opts: Record<string, unknown>
51
+ ) => Promise<Record<string, unknown> | null>;
52
+ };
53
+ try {
54
+ exifr =
55
+ ((await import('exifr')) as { default?: typeof exifr }).default ??
56
+ ((await import('exifr')) as unknown as typeof exifr);
57
+ } catch {
58
+ return {
59
+ trustWarnings: [
60
+ 'Optional dependency `exifr` is not installed; EXIF/XMP/IPTC metadata skipped (geometry/OCR still available).',
61
+ ],
62
+ };
63
+ }
49
64
  const parsed = await exifr.parse(filePath, {
50
65
  tiff: true,
51
66
  xmp: true,
@@ -140,6 +155,28 @@ export const readImage = tool()
140
155
  twin.metadata = extractedMetadata;
141
156
  }
142
157
  } else {
158
+ type SharpMeta = {
159
+ width?: number;
160
+ height?: number;
161
+ format?: string;
162
+ orientation?: number;
163
+ space?: string;
164
+ hasAlpha?: boolean;
165
+ };
166
+ type SharpFn = (
167
+ path: string,
168
+ opts?: { failOn?: string }
169
+ ) => { metadata: () => Promise<SharpMeta> };
170
+ let sharp: SharpFn;
171
+ try {
172
+ const mod = await import('sharp');
173
+ sharp = (mod as { default?: SharpFn }).default ?? (mod as unknown as SharpFn);
174
+ } catch {
175
+ throw new ImageError(
176
+ ErrorCode.InvalidRequest,
177
+ 'Rust decode engine is unavailable and optional `sharp` is not installed. Build/stage image-reader-cli or install sharp for the TS fallback.'
178
+ );
179
+ }
143
180
  const image = sharp(resolvedPath, { failOn: 'none' });
144
181
  const metadata = await image.metadata();
145
182
  validateImageSafety({
@@ -74,7 +74,9 @@ export const readImageArgsSchema = z.object({
74
74
  include_palette: z
75
75
  .boolean()
76
76
  .optional()
77
- .describe('Sample an approximate local color palette via sharp (not ML). Defaults to false.'),
77
+ .describe(
78
+ 'Sample an approximate local color palette via optional sharp when installed (not ML). Defaults to false.'
79
+ ),
78
80
  include_optional_llm: z
79
81
  .boolean()
80
82
  .optional()
@@ -1,36 +1,51 @@
1
- import sharp from 'sharp';
1
+ /** Cheap local palette sample via optional sharp stats (not ML segmentation). */
2
2
 
3
- /** Cheap local palette sample via sharp stats (not ML segmentation). */
4
- export async function samplePalette(
5
- filePath: string,
6
- maxColors = 5
7
- ): Promise<Array<{ hex: string; approx_share: number }>> {
8
- const image = sharp(filePath, { failOn: 'none' }).resize(64, 64, { fit: 'inside' });
9
- const stats = await image.stats();
10
- const dominant = stats.dominant;
11
- if (!dominant) return [];
12
-
13
- const hex = (r: number, g: number, b: number) =>
14
- `#${[r, g, b]
15
- .map((v) =>
16
- Math.max(0, Math.min(255, Math.round(v)))
17
- .toString(16)
18
- .padStart(2, '0')
19
- )
20
- .join('')}`;
21
-
22
- const colors: Array<{ hex: string; approx_share: number }> = [
23
- { hex: hex(dominant.r, dominant.g, dominant.b), approx_share: 0.55 },
24
- ];
3
+ type SharpLike = (
4
+ input: string,
5
+ opts?: { failOn?: string }
6
+ ) => {
7
+ resize: (
8
+ w: number,
9
+ h: number,
10
+ opts: { fit: string }
11
+ ) => {
12
+ stats: () => Promise<{
13
+ dominant?: { r: number; g: number; b: number };
14
+ channels?: Array<{ mean?: number }>;
15
+ }>;
16
+ };
17
+ };
25
18
 
26
- // Channel means as a second honest swatch (not true multi-color clustering).
27
- const ch = stats.channels;
28
- if (ch && ch.length >= 3) {
29
- colors.push({
30
- hex: hex(ch[0]?.mean ?? dominant.r, ch[1]?.mean ?? dominant.g, ch[2]?.mean ?? dominant.b),
31
- approx_share: 0.45,
32
- });
19
+ export async function samplePalette(
20
+ filePath: string
21
+ ): Promise<Array<{ hex: string; approx_share: number }> | undefined> {
22
+ let sharp: SharpLike;
23
+ try {
24
+ const mod = await import('sharp');
25
+ const candidate = (mod as { default?: SharpLike }).default ?? (mod as unknown as SharpLike);
26
+ sharp = candidate;
27
+ } catch {
28
+ return undefined;
33
29
  }
34
30
 
35
- return colors.slice(0, maxColors);
31
+ try {
32
+ const image = sharp(filePath, { failOn: 'none' }).resize(64, 64, { fit: 'inside' });
33
+ const stats = await image.stats();
34
+ const channels = stats.channels ?? [];
35
+ if (stats.dominant) {
36
+ const { r, g, b } = stats.dominant;
37
+ const hex = `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}`;
38
+ return [{ hex, approx_share: 1 }];
39
+ }
40
+ if (channels.length >= 3) {
41
+ const r = Math.round(channels[0]?.mean ?? 0);
42
+ const g = Math.round(channels[1]?.mean ?? 0);
43
+ const b = Math.round(channels[2]?.mean ?? 0);
44
+ const hex = `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}`;
45
+ return [{ hex, approx_share: 1 }];
46
+ }
47
+ return undefined;
48
+ } catch {
49
+ return undefined;
50
+ }
36
51
  }
Binary file