mcp-duojie-image 0.5.8 → 0.7.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 CHANGED
@@ -5,7 +5,9 @@ MCP server for image generation via duojie.games GPT image API.
5
5
  ## Features
6
6
 
7
7
  - Text-to-image generation with `gpt-image-2`
8
- - Reference image editing via local image paths
8
+ - Reference image editing via local image paths, with optional image roles
9
+ - Prompt structuring helpers for use case, asset type, constraints, and effective prompt echo
10
+ - Optional chroma-key transparent background post-processing
9
11
  - Async submission with task status lookup
10
12
  - File saving with overwrite/conflict handling
11
13
  - Verified size templates for `1K`, `2K`, and `4K`
@@ -37,12 +39,30 @@ Optional parameters:
37
39
  |---|---|---:|---|
38
40
  | `size` | `1K` \| `2K` \| `4K` | `1K` | Output size tier |
39
41
  | `aspect_ratio` | `1:1` \| `3:2` \| `2:3` \| `16:9` \| `9:16` | `1:1` | Verified ratio template |
40
- | `reference_images` | string[] | - | Local reference image paths |
41
- | `format` | string | `png` | Output format |
42
+ | `reference_images` | string[] | - | Legacy local reference image paths |
43
+ | `input_images` | object[] | - | Structured local image inputs with `path`, optional `role`, and optional `note` |
44
+ | `use_case` | string | - | Prompt taxonomy slug, for example `product-mockup` or `background-extraction` |
45
+ | `asset_type` | string | - | Where the asset will be used |
46
+ | `constraints` | string | - | Must-keep or must-avoid constraints |
47
+ | `transparent_background` | boolean | `false` | Add chroma-key prompt instructions and remove the flat key color locally; PNG only |
48
+ | `transparent_key_color` | string | `#00ff00` | Chroma-key color for transparent background requests |
49
+ | `format` | `png` \| `jpg` \| `jpeg` | `png` | Output format; content is encoded to match the extension |
42
50
  | `overwrite` | boolean | `false` | Overwrite existing file |
43
51
  | `negative_prompt` | string | - | Content to avoid |
52
+ | `reference_max_side` | number | `1024` | Resize reference inputs to this max side before upload |
53
+ | `preserve_reference_detail` | boolean | `false` | Upload reference inputs without resizing |
44
54
  | `silent` | boolean | `false` | Disable desktop notification |
45
55
 
56
+ Supported `input_images.role` values:
57
+
58
+ - `reference`
59
+ - `edit_target`
60
+ - `style_reference`
61
+ - `composition_reference`
62
+ - `insert_source`
63
+
64
+ When both `input_images` and `reference_images` are provided, `input_images` takes precedence. `generate_image` returns `effective_prompt` so callers can diagnose the exact prompt sent upstream.
65
+
46
66
  Response:
47
67
 
48
68
  ```json
@@ -50,6 +70,7 @@ Response:
50
70
  "success": true,
51
71
  "status": "submitted",
52
72
  "task_id": "uuid",
73
+ "effective_prompt": "Primary request: ...",
53
74
  "message": "已提交异步生成;如需诊断失败原因,可调用 check_image_task"
54
75
  }
55
76
  ```
@@ -69,7 +90,8 @@ Done response:
69
90
  "success": true,
70
91
  "task_id": "uuid",
71
92
  "status": "done",
72
- "saved_path": "D:/images/output.png"
93
+ "saved_path": "D:/images/output.png",
94
+ "effective_prompt": "Primary request: ..."
73
95
  }
74
96
  ```
75
97
 
@@ -102,6 +124,9 @@ These templates were tested directly against `/v1/images/generations`; all retur
102
124
  ```text
103
125
  generate_image(
104
126
  prompt="A futuristic city at sunset",
127
+ use_case="stylized-concept",
128
+ asset_type="website hero",
129
+ constraints="no text, no watermark",
105
130
  size="2K",
106
131
  aspect_ratio="16:9",
107
132
  output_dir="D:/ai-images",
@@ -0,0 +1,84 @@
1
+ import sharp from "sharp";
2
+ import { AppError } from "./errors.js";
3
+ export const OUTPUT_FORMATS = ["png", "jpg", "jpeg", "webp"];
4
+ export function normalizeOutputFormat(format) {
5
+ const normalized = format.toLowerCase().replace(/^\./, "");
6
+ if (!OUTPUT_FORMATS.includes(normalized)) {
7
+ throw new AppError("E_INPUT_INVALID", `Unsupported output format: ${format}. Supported formats: ${OUTPUT_FORMATS.join(", ")}`);
8
+ }
9
+ return normalized;
10
+ }
11
+ function parseHexColor(color) {
12
+ const hex = color.trim().replace(/^#/, "");
13
+ if (!/^[0-9a-fA-F]{6}$/.test(hex)) {
14
+ throw new AppError("E_INPUT_INVALID", `Invalid transparent_key_color: ${color}`);
15
+ }
16
+ return {
17
+ r: Number.parseInt(hex.slice(0, 2), 16),
18
+ g: Number.parseInt(hex.slice(2, 4), 16),
19
+ b: Number.parseInt(hex.slice(4, 6), 16),
20
+ };
21
+ }
22
+ function distance(a, b) {
23
+ return Math.sqrt(Math.pow(a.r - b.r, 2) +
24
+ Math.pow(a.g - b.g, 2) +
25
+ Math.pow(a.b - b.b, 2));
26
+ }
27
+ export async function encodeImage(imageBuffer, format) {
28
+ if (format === "png") {
29
+ return imageBuffer;
30
+ }
31
+ try {
32
+ const image = sharp(imageBuffer);
33
+ if (format === "webp") {
34
+ return await image.webp({ quality: 90 }).toBuffer();
35
+ }
36
+ // jpg / jpeg
37
+ return await image.jpeg({ quality: 90 }).toBuffer();
38
+ }
39
+ catch (err) {
40
+ throw new AppError("E_SAVE_FAILED", `Cannot encode image as ${format}: ${err}`);
41
+ }
42
+ }
43
+ export async function removeChromaKey(imageBuffer, keyColor = "#00ff00") {
44
+ const key = parseHexColor(keyColor);
45
+ const image = sharp(imageBuffer, { failOn: "none" }).ensureAlpha();
46
+ let metadata;
47
+ try {
48
+ metadata = await image.metadata();
49
+ }
50
+ catch (err) {
51
+ throw new AppError("E_SAVE_FAILED", `Cannot read image for chroma-key removal: ${err}`);
52
+ }
53
+ const width = metadata.width;
54
+ const height = metadata.height;
55
+ if (!width || !height) {
56
+ throw new AppError("E_SAVE_FAILED", "Cannot read image dimensions for chroma-key removal");
57
+ }
58
+ const data = await image.raw().toBuffer();
59
+ let transparentPixels = 0;
60
+ const hardThreshold = 34;
61
+ const softThreshold = 96;
62
+ for (let offset = 0; offset < data.length; offset += 4) {
63
+ const pixel = {
64
+ r: data[offset],
65
+ g: data[offset + 1],
66
+ b: data[offset + 2],
67
+ };
68
+ const dist = distance(pixel, key);
69
+ if (dist <= hardThreshold) {
70
+ data[offset + 3] = 0;
71
+ transparentPixels++;
72
+ }
73
+ else if (dist <= softThreshold) {
74
+ const alpha = Math.round(((dist - hardThreshold) / (softThreshold - hardThreshold)) * 255);
75
+ data[offset + 3] = Math.min(data[offset + 3], alpha);
76
+ transparentPixels++;
77
+ }
78
+ }
79
+ const totalPixels = width * height;
80
+ if (transparentPixels < totalPixels * 0.01) {
81
+ throw new AppError("E_SAVE_FAILED", "Chroma-key removal did not find enough background pixels; try a simpler subject or a different transparent_key_color");
82
+ }
83
+ return sharp(data, { raw: { width, height, channels: 4 } }).png().toBuffer();
84
+ }
package/dist/index.js CHANGED
@@ -8,6 +8,8 @@ import { ASPECT_RATIOS, IMAGE_SIZES, generateImage } from "./provider.js";
8
8
  import { saveImage } from "./saver.js";
9
9
  import { AppError } from "./errors.js";
10
10
  import { createTask, getTask, updateTask } from "./task-queue.js";
11
+ import { IMAGE_INPUT_ROLES, PROMPT_USE_CASES, preparePrompt } from "./prompt.js";
12
+ import { OUTPUT_FORMATS, removeChromaKey } from "./image-processing.js";
11
13
  const require = createRequire(import.meta.url);
12
14
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
15
  const notifier = require("node-notifier");
@@ -38,11 +40,23 @@ server.registerTool("generate_image", {
38
40
  output_dir: z.string().describe("Absolute output dir"),
39
41
  output_filename: z.string().describe("Filename without ext"),
40
42
  reference_images: z.array(z.string()).optional().describe("Reference image paths"),
43
+ input_images: z.array(z.object({
44
+ path: z.string().min(1).describe("Local image path"),
45
+ role: z.enum(IMAGE_INPUT_ROLES).optional().describe("How this image should guide generation/editing"),
46
+ note: z.string().optional().describe("Short role-specific instruction"),
47
+ })).optional().describe("Structured image inputs with roles; supersedes reference_images when provided"),
48
+ use_case: z.enum(PROMPT_USE_CASES).optional().describe("Prompt taxonomy slug"),
49
+ asset_type: z.string().optional().describe("Where the image will be used"),
50
+ constraints: z.string().optional().describe("Must-keep or must-avoid constraints"),
51
+ transparent_background: z.boolean().optional().describe("Generate on a chroma-key background and remove it locally; PNG only"),
52
+ transparent_key_color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe("Chroma-key color, default #00ff00"),
41
53
  size: z.enum(IMAGE_SIZES).optional().describe("Default 1K. Supported: 1K, 2K, 4K"),
42
54
  aspect_ratio: z.enum(ASPECT_RATIOS).optional().describe("Supported: 1:1, 3:2, 2:3, 16:9, 9:16"),
43
- format: z.string().optional().describe("Default png"),
55
+ format: z.enum(OUTPUT_FORMATS).optional().describe("Default png. Supported: png, jpg, jpeg"),
44
56
  overwrite: z.boolean().optional().describe("Default false"),
45
57
  negative_prompt: z.string().optional().describe("Avoid"),
58
+ reference_max_side: z.number().int().min(256).max(3840).optional().describe("Resize reference inputs to this max side; default 1024"),
59
+ preserve_reference_detail: z.boolean().optional().describe("Do not resize reference inputs before upload"),
46
60
  silent: z.boolean().optional().describe("No notification"),
47
61
  }),
48
62
  }, async (args) => {
@@ -55,22 +69,61 @@ server.registerTool("generate_image", {
55
69
  return { content: [{ type: "text", text: JSON.stringify({ success: false, error: code, message: String(err instanceof Error ? err.message : err) }) }], isError: true };
56
70
  }
57
71
  const silent = args.silent ?? false;
72
+ if (args.transparent_background) {
73
+ const filenameExt = args.output_filename.match(/\.([^.]+)$/)?.[1]?.toLowerCase();
74
+ if ((args.format && args.format !== "png") || (filenameExt && filenameExt !== "png")) {
75
+ return {
76
+ content: [{
77
+ type: "text",
78
+ text: JSON.stringify({
79
+ success: false,
80
+ error: "E_INPUT_INVALID",
81
+ message: "transparent_background requires PNG output",
82
+ }),
83
+ }],
84
+ isError: true,
85
+ };
86
+ }
87
+ }
88
+ let preparedPrompt;
89
+ try {
90
+ preparedPrompt = preparePrompt({
91
+ prompt: args.prompt,
92
+ negativePrompt: args.negative_prompt,
93
+ useCase: args.use_case,
94
+ assetType: args.asset_type,
95
+ constraints: args.constraints,
96
+ inputImages: args.input_images,
97
+ referenceImages: args.reference_images,
98
+ transparentBackground: args.transparent_background,
99
+ transparentKeyColor: args.transparent_key_color,
100
+ });
101
+ }
102
+ catch (err) {
103
+ const code = err instanceof AppError ? err.code : "E_INPUT_INVALID";
104
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: code, message: String(err instanceof Error ? err.message : err) }) }], isError: true };
105
+ }
58
106
  const task = createTask();
107
+ updateTask(task.id, { effectivePrompt: preparedPrompt.prompt });
59
108
  // Start background generation (don't await)
60
109
  (async () => {
61
110
  try {
62
111
  updateTask(task.id, { status: "running", startedAt: Date.now() });
63
- const imageBuffer = await generateImage(config, {
64
- prompt: args.prompt,
65
- referenceImages: args.reference_images,
66
- negativePrompt: args.negative_prompt,
112
+ let imageBuffer = await generateImage(config, {
113
+ prompt: preparedPrompt.prompt,
114
+ referenceImages: preparedPrompt.imagePaths,
67
115
  size: args.size ?? "1K",
68
116
  aspectRatio: args.aspect_ratio,
117
+ referenceMaxSide: args.preserve_reference_detail ? false : args.reference_max_side,
69
118
  });
70
- const savedPath = saveImage(imageBuffer, {
119
+ const outputFormat = args.transparent_background ? "png" : args.format;
120
+ if (args.transparent_background) {
121
+ imageBuffer = await removeChromaKey(imageBuffer, args.transparent_key_color);
122
+ }
123
+ const savedPath = await saveImage(imageBuffer, {
71
124
  outputDir: args.output_dir,
72
125
  outputFilename: args.output_filename,
73
- format: args.format ?? "png",
126
+ format: outputFormat,
74
127
  overwrite: args.overwrite ?? false,
75
128
  });
76
129
  updateTask(task.id, {
@@ -115,6 +168,7 @@ server.registerTool("generate_image", {
115
168
  success: true,
116
169
  status: "submitted",
117
170
  task_id: task.id,
171
+ effective_prompt: preparedPrompt.prompt,
118
172
  message: `已提交异步生成:${args.output_filename};如需诊断失败原因,可调用 check_image_task`,
119
173
  }),
120
174
  }],
@@ -145,6 +199,9 @@ server.registerTool("check_image_task", {
145
199
  if (task.status === "done") {
146
200
  response.saved_path = task.savedPath;
147
201
  }
202
+ if (task.effectivePrompt) {
203
+ response.effective_prompt = task.effectivePrompt;
204
+ }
148
205
  if (task.status === "failed") {
149
206
  response.error = task.errorCode;
150
207
  response.message = task.error;
package/dist/prompt.js ADDED
@@ -0,0 +1,87 @@
1
+ import { AppError } from "./errors.js";
2
+ export const IMAGE_INPUT_ROLES = [
3
+ "reference",
4
+ "edit_target",
5
+ "style_reference",
6
+ "composition_reference",
7
+ "insert_source",
8
+ ];
9
+ export const PROMPT_USE_CASES = [
10
+ "photorealistic-natural",
11
+ "product-mockup",
12
+ "ui-mockup",
13
+ "infographic-diagram",
14
+ "scientific-educational",
15
+ "ads-marketing",
16
+ "productivity-visual",
17
+ "logo-brand",
18
+ "illustration-story",
19
+ "stylized-concept",
20
+ "historical-scene",
21
+ "text-localization",
22
+ "identity-preserve",
23
+ "precise-object-edit",
24
+ "lighting-weather",
25
+ "background-extraction",
26
+ "style-transfer",
27
+ "compositing",
28
+ "sketch-to-render",
29
+ ];
30
+ const DEFAULT_KEY_COLOR = "#00ff00";
31
+ function compactLines(lines) {
32
+ return lines.filter((line) => Boolean(line?.trim())).join("\n");
33
+ }
34
+ function validateImageInputs(inputImages) {
35
+ if (!inputImages)
36
+ return undefined;
37
+ for (const [index, image] of inputImages.entries()) {
38
+ if (!image.path?.trim()) {
39
+ throw new AppError("E_INPUT_INVALID", `input_images[${index}].path is required`);
40
+ }
41
+ }
42
+ return inputImages;
43
+ }
44
+ function imageRoleLabel(role) {
45
+ return role?.replace(/_/g, " ") ?? "reference";
46
+ }
47
+ function imageLines(inputImages) {
48
+ if (!inputImages?.length)
49
+ return undefined;
50
+ const lines = inputImages.map((image, index) => {
51
+ const note = image.note?.trim() ? `; ${image.note.trim()}` : "";
52
+ return `Image ${index + 1}: ${imageRoleLabel(image.role)}${note}`;
53
+ });
54
+ return `Input images: ${lines.join("; ")}`;
55
+ }
56
+ function legacyReferenceInputs(referenceImages) {
57
+ if (!referenceImages?.length)
58
+ return undefined;
59
+ return referenceImages.map((path) => ({ path, role: "reference" }));
60
+ }
61
+ function transparentPrompt(keyColor) {
62
+ return compactLines([
63
+ `Scene/backdrop: perfectly flat solid ${keyColor} chroma-key background for local background removal.`,
64
+ "Constraints: background must be one uniform color with no shadows, gradients, texture, reflections, floor plane, or lighting variation; keep the subject fully separated from the background with crisp edges and generous padding.",
65
+ `Avoid: do not use ${keyColor} anywhere in the subject; no cast shadow, no contact shadow, no reflection, no watermark, and no text unless explicitly requested.`,
66
+ ]);
67
+ }
68
+ export function preparePrompt(opts) {
69
+ const inputImages = validateImageInputs(opts.inputImages) ?? legacyReferenceInputs(opts.referenceImages);
70
+ const keyColor = opts.transparentKeyColor ?? DEFAULT_KEY_COLOR;
71
+ const negativePrompt = opts.negativePrompt?.trim()
72
+ ? `Avoid: ${opts.negativePrompt.trim()}`
73
+ : undefined;
74
+ const prompt = compactLines([
75
+ opts.useCase ? `Use case: ${opts.useCase}` : undefined,
76
+ opts.assetType ? `Asset type: ${opts.assetType}` : undefined,
77
+ imageLines(inputImages),
78
+ `Primary request: ${opts.prompt.trim()}`,
79
+ opts.constraints ? `Constraints: ${opts.constraints.trim()}` : undefined,
80
+ opts.transparentBackground ? transparentPrompt(keyColor) : undefined,
81
+ negativePrompt,
82
+ ]);
83
+ return {
84
+ prompt,
85
+ imagePaths: inputImages?.map((image) => image.path) ?? [],
86
+ };
87
+ }
package/dist/provider.js CHANGED
@@ -10,11 +10,28 @@ async function fetchWithRetry(url, init, timeoutMs, retries = 0) {
10
10
  try {
11
11
  const res = await fetch(url, { ...init, signal: controller.signal });
12
12
  clearTimeout(timer);
13
+ // 5xx: 上游随机故障(EOF等),立刻 abort body 连接并重试,不等 chunked 终止
14
+ if (res.status >= 500 && attempt < retries) {
15
+ try {
16
+ controller.abort();
17
+ }
18
+ catch { }
19
+ const delayMs = 1000 * Math.pow(2, attempt) + Math.random() * 500;
20
+ await new Promise((r) => setTimeout(r, delayMs));
21
+ continue;
22
+ }
13
23
  return res;
14
24
  }
15
25
  catch (err) {
16
26
  clearTimeout(timer);
17
27
  const isAbort = err instanceof Error && err.name === "AbortError";
28
+ // AbortError from our manual abort above is not a real timeout
29
+ if (isAbort && attempt < retries) {
30
+ lastErr = err;
31
+ const delayMs = 1000 * Math.pow(2, attempt) + Math.random() * 500;
32
+ await new Promise((r) => setTimeout(r, delayMs));
33
+ continue;
34
+ }
18
35
  if (isAbort) {
19
36
  throw new AppError("E_PROVIDER_TIMEOUT", "Request timed out");
20
37
  }
@@ -77,11 +94,15 @@ async function handleProviderError(res) {
77
94
  if (res.status === 429) {
78
95
  throw new AppError("E_PROVIDER_RATE_LIMIT", "Rate limit exceeded");
79
96
  }
97
+ // 5xx: 上游随机故障,不读 body(chunked 连接挂 120s),直接秒回
98
+ if (res.status >= 500) {
99
+ throw new AppError("E_PROVIDER_BAD_REQUEST", `Upstream error ${res.status} (body skipped to avoid chunked hang)`);
100
+ }
80
101
  const text = await res.text().catch(() => "");
81
102
  throw new AppError("E_PROVIDER_BAD_REQUEST", `Upstream error ${res.status}: ${text.slice(0, 200)}`);
82
103
  }
83
104
  async function generateOpenAIImage(config, opts) {
84
- const prompt = opts.negativePrompt ? `${opts.prompt}\n\nAvoid: ${opts.negativePrompt}` : opts.prompt;
105
+ const prompt = opts.prompt;
85
106
  const size = mapOpenAISize(opts.size, opts.aspectRatio);
86
107
  const hasReferenceImages = Boolean(opts.referenceImages?.length);
87
108
  const url = `${config.baseUrl}${hasReferenceImages ? "/v1/images/edits" : "/v1/images/generations"}`;
@@ -98,9 +119,9 @@ async function generateOpenAIImage(config, opts) {
98
119
  form.set("aspect_ratio", opts.aspectRatio);
99
120
  }
100
121
  for (const [index, imgPath] of opts.referenceImages.entries()) {
101
- const data = await resizeIfNeeded(imgPath);
122
+ const data = await resizeIfNeeded(imgPath, opts.referenceMaxSide);
102
123
  const bytes = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
103
- form.append("image", new Blob([bytes], { type: "image/png" }), `reference-${index}.png`);
124
+ form.append("image", new Blob([bytes], { type: "image/webp" }), `reference-${index}.webp`);
104
125
  }
105
126
  body = form;
106
127
  }
@@ -113,11 +134,13 @@ async function generateOpenAIImage(config, opts) {
113
134
  ...(opts.aspectRatio ? { aspect_ratio: opts.aspectRatio } : {}),
114
135
  });
115
136
  }
137
+ // 启用 2 次重试,应对上游偶发的 EOF / 524(Cloudflare 100s 超时)。
138
+ // retries=0 时所有偶发失败一次过即死,指数退避代码是死代码。
116
139
  const res = await fetchWithRetry(url, {
117
140
  method: "POST",
118
141
  headers,
119
142
  body,
120
- }, config.timeoutMs);
143
+ }, config.timeoutMs, 2);
121
144
  if (!res.ok) {
122
145
  await handleProviderError(res);
123
146
  }
package/dist/resizer.js CHANGED
@@ -1,17 +1,39 @@
1
- import { Jimp, JimpMime } from "jimp";
1
+ import sharp from "sharp";
2
+ import { existsSync } from "node:fs";
2
3
  import { AppError } from "./errors.js";
3
- const MAX_SIDE = 1024;
4
- export async function resizeIfNeeded(imgPath) {
5
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
- let image;
4
+ export const DEFAULT_REFERENCE_MAX_SIDE = 1024;
5
+ /**
6
+ * 读入垫图,按需缩放到 maxSide 内(保持比例不放大),
7
+ * 强制重编码为 WebP(quality 85)以满足 gpt-image-2 edits 端点的 1.5MB 甜点值约束。
8
+ * PNG 通道比 WebP 大 3~5 倍,改用 WebP 后体积大幅下降,避免上游 100s 超时。
9
+ */
10
+ export async function resizeIfNeeded(imgPath, maxSide = DEFAULT_REFERENCE_MAX_SIDE) {
11
+ if (!existsSync(imgPath)) {
12
+ throw new AppError("E_REFERENCE_NOT_FOUND", `Cannot read reference image: ${imgPath}`);
13
+ }
14
+ let pipeline = sharp(imgPath, { failOn: "none" });
15
+ if (maxSide !== false) {
16
+ try {
17
+ const metadata = await pipeline.metadata();
18
+ if (metadata.width &&
19
+ metadata.height &&
20
+ (metadata.width > maxSide || metadata.height > maxSide)) {
21
+ pipeline = pipeline.resize({
22
+ width: maxSide,
23
+ height: maxSide,
24
+ fit: "inside",
25
+ withoutEnlargement: true,
26
+ });
27
+ }
28
+ }
29
+ catch (err) {
30
+ throw new AppError("E_REFERENCE_NOT_FOUND", `Cannot read reference image: ${imgPath}`);
31
+ }
32
+ }
7
33
  try {
8
- image = await Jimp.read(imgPath);
34
+ return await pipeline.webp({ quality: 85 }).toBuffer();
9
35
  }
10
- catch {
36
+ catch (err) {
11
37
  throw new AppError("E_REFERENCE_NOT_FOUND", `Cannot read reference image: ${imgPath}`);
12
38
  }
13
- if (image.width > MAX_SIDE || image.height > MAX_SIDE) {
14
- image.scaleToFit({ w: MAX_SIDE, h: MAX_SIDE });
15
- }
16
- return image.getBuffer(JimpMime.png);
17
39
  }
package/dist/saver.js CHANGED
@@ -1,8 +1,21 @@
1
1
  import { mkdirSync, existsSync, writeFileSync } from "node:fs";
2
2
  import { resolve, join, extname, basename } from "node:path";
3
3
  import { AppError } from "./errors.js";
4
- export function saveImage(imageBuffer, opts) {
5
- const { outputDir, outputFilename, format = "png", overwrite = false } = opts;
4
+ import { encodeImage, normalizeOutputFormat } from "./image-processing.js";
5
+ function resolveFormat(outputFilename, format) {
6
+ const filenameExt = extname(outputFilename).replace(/^\./, "");
7
+ const requested = normalizeOutputFormat(format ?? "png");
8
+ if (!filenameExt)
9
+ return requested;
10
+ const filenameFormat = normalizeOutputFormat(filenameExt);
11
+ if (format && filenameFormat !== requested) {
12
+ throw new AppError("E_INPUT_INVALID", `Filename extension .${filenameExt} does not match requested format ${format}`);
13
+ }
14
+ return filenameFormat;
15
+ }
16
+ export async function saveImage(imageBuffer, opts) {
17
+ const { outputDir, outputFilename, overwrite = false } = opts;
18
+ const outputFormat = resolveFormat(outputFilename, opts.format);
6
19
  const absDir = resolve(outputDir);
7
20
  try {
8
21
  mkdirSync(absDir, { recursive: true });
@@ -10,7 +23,7 @@ export function saveImage(imageBuffer, opts) {
10
23
  catch (err) {
11
24
  throw new AppError("E_SAVE_FAILED", `Cannot create directory: ${absDir} - ${err}`);
12
25
  }
13
- const ext = extname(outputFilename) || `.${format}`;
26
+ const ext = extname(outputFilename) || `.${outputFormat}`;
14
27
  const base = extname(outputFilename) ? basename(outputFilename, ext) : outputFilename;
15
28
  let finalPath = join(absDir, `${base}${ext}`);
16
29
  if (!overwrite && existsSync(finalPath)) {
@@ -21,7 +34,7 @@ export function saveImage(imageBuffer, opts) {
21
34
  } while (existsSync(finalPath));
22
35
  }
23
36
  try {
24
- writeFileSync(finalPath, imageBuffer);
37
+ writeFileSync(finalPath, await encodeImage(imageBuffer, outputFormat));
25
38
  }
26
39
  catch (err) {
27
40
  throw new AppError("E_SAVE_FAILED", `Cannot write file: ${finalPath} - ${err}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-duojie-image",
3
- "version": "0.5.8",
3
+ "version": "0.7.0",
4
4
  "description": "MCP server for image generation via duojie.games",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,8 +26,8 @@
26
26
  "license": "MIT",
27
27
  "dependencies": {
28
28
  "@modelcontextprotocol/sdk": "^1.12.0",
29
- "jimp": "^1.6.0",
30
29
  "node-notifier": "^10.0.1",
30
+ "sharp": "^0.35.3",
31
31
  "zod": "^3.23.8"
32
32
  },
33
33
  "devDependencies": {