mcp-duojie-image 0.5.7 → 0.6.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`
@@ -18,7 +20,6 @@ Required:
18
20
  |---|---|---|
19
21
  | `DUOJIE_API_KEY` | required | duojie.games API key |
20
22
  | `DUOJIE_BASE_URL` | `https://api.duojie.games` | API base URL |
21
- | `DUOJIE_IMAGE_MODEL` | `gpt-image-2` | Optional; only `gpt-image-2` is supported |
22
23
 
23
24
  ## Tool: `generate_image`
24
25
 
@@ -31,7 +32,6 @@ Required parameters:
31
32
  | `prompt` | string | Image prompt |
32
33
  | `output_dir` | string | Absolute output directory |
33
34
  | `output_filename` | string | Filename without extension |
34
- | `model` | `gpt-image-2` | Only supported model |
35
35
 
36
36
  Optional parameters:
37
37
 
@@ -39,12 +39,30 @@ Optional parameters:
39
39
  |---|---|---:|---|
40
40
  | `size` | `1K` \| `2K` \| `4K` | `1K` | Output size tier |
41
41
  | `aspect_ratio` | `1:1` \| `3:2` \| `2:3` \| `16:9` \| `9:16` | `1:1` | Verified ratio template |
42
- | `reference_images` | string[] | - | Local reference image paths |
43
- | `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 |
44
50
  | `overwrite` | boolean | `false` | Overwrite existing file |
45
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 |
46
54
  | `silent` | boolean | `false` | Disable desktop notification |
47
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
+
48
66
  Response:
49
67
 
50
68
  ```json
@@ -52,6 +70,7 @@ Response:
52
70
  "success": true,
53
71
  "status": "submitted",
54
72
  "task_id": "uuid",
73
+ "effective_prompt": "Primary request: ...",
55
74
  "message": "已提交异步生成;如需诊断失败原因,可调用 check_image_task"
56
75
  }
57
76
  ```
@@ -72,7 +91,7 @@ Done response:
72
91
  "task_id": "uuid",
73
92
  "status": "done",
74
93
  "saved_path": "D:/images/output.png",
75
- "model": "gpt-image-2"
94
+ "effective_prompt": "Primary request: ..."
76
95
  }
77
96
  ```
78
97
 
@@ -105,7 +124,9 @@ These templates were tested directly against `/v1/images/generations`; all retur
105
124
  ```text
106
125
  generate_image(
107
126
  prompt="A futuristic city at sunset",
108
- model="gpt-image-2",
127
+ use_case="stylized-concept",
128
+ asset_type="website hero",
129
+ constraints="no text, no watermark",
109
130
  size="2K",
110
131
  aspect_ratio="16:9",
111
132
  output_dir="D:/ai-images",
package/dist/config.js CHANGED
@@ -9,14 +9,9 @@ export function loadConfig(overrides) {
9
9
  if (!apiKey) {
10
10
  throw new AppError("E_CONFIG_MISSING_KEY", "DUOJIE_API_KEY is not set");
11
11
  }
12
- const model = overrides?.model ?? process.env.DUOJIE_IMAGE_MODEL ?? "gpt-image-2";
13
- if (model !== "gpt-image-2") {
14
- throw new AppError("E_INPUT_INVALID", "Only gpt-image-2 is supported");
15
- }
16
12
  return {
17
13
  apiKey,
18
14
  baseUrl: overrides?.baseUrl ?? process.env.DUOJIE_BASE_URL ?? "https://api.duojie.games",
19
- model,
20
15
  timeoutMs: 300_000,
21
16
  };
22
17
  }
@@ -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");
@@ -32,18 +34,29 @@ const server = new McpServer({
32
34
  version: "0.1.0",
33
35
  });
34
36
  server.registerTool("generate_image", {
35
- description: "Generate image async. If user didn't specify model and no model was chosen in this conversation, ask once. Reuse that model later unless user changes it. Return after tool call; don't wait/check.",
37
+ description: "Generate image async with gpt-image-2. Return after tool call; don't wait/check.",
36
38
  inputSchema: z.object({
37
39
  prompt: z.string().min(1).describe("Prompt"),
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"),
44
- model: z.enum(["gpt-image-2"]).describe("Required; only gpt-image-2 is supported"),
55
+ format: z.enum(OUTPUT_FORMATS).optional().describe("Default png. Supported: png, jpg, jpeg"),
45
56
  overwrite: z.boolean().optional().describe("Default false"),
46
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"),
47
60
  silent: z.boolean().optional().describe("No notification"),
48
61
  }),
49
62
  }, async (args) => {
@@ -56,30 +69,67 @@ server.registerTool("generate_image", {
56
69
  return { content: [{ type: "text", text: JSON.stringify({ success: false, error: code, message: String(err instanceof Error ? err.message : err) }) }], isError: true };
57
70
  }
58
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
+ }
59
106
  const task = createTask();
107
+ updateTask(task.id, { effectivePrompt: preparedPrompt.prompt });
60
108
  // Start background generation (don't await)
61
109
  (async () => {
62
110
  try {
63
111
  updateTask(task.id, { status: "running", startedAt: Date.now() });
64
- const imageBuffer = await generateImage(config, {
65
- prompt: args.prompt,
66
- referenceImages: args.reference_images,
67
- model: args.model,
68
- negativePrompt: args.negative_prompt,
112
+ let imageBuffer = await generateImage(config, {
113
+ prompt: preparedPrompt.prompt,
114
+ referenceImages: preparedPrompt.imagePaths,
69
115
  size: args.size ?? "1K",
70
116
  aspectRatio: args.aspect_ratio,
117
+ referenceMaxSide: args.preserve_reference_detail ? false : args.reference_max_side,
71
118
  });
72
- 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, {
73
124
  outputDir: args.output_dir,
74
125
  outputFilename: args.output_filename,
75
- format: args.format ?? "png",
126
+ format: outputFormat,
76
127
  overwrite: args.overwrite ?? false,
77
128
  });
78
129
  updateTask(task.id, {
79
130
  status: "done",
80
131
  completedAt: Date.now(),
81
132
  savedPath,
82
- model: args.model,
83
133
  });
84
134
  await server.sendLoggingMessage({
85
135
  level: "info",
@@ -118,6 +168,7 @@ server.registerTool("generate_image", {
118
168
  success: true,
119
169
  status: "submitted",
120
170
  task_id: task.id,
171
+ effective_prompt: preparedPrompt.prompt,
121
172
  message: `已提交异步生成:${args.output_filename};如需诊断失败原因,可调用 check_image_task`,
122
173
  }),
123
174
  }],
@@ -147,7 +198,9 @@ server.registerTool("check_image_task", {
147
198
  };
148
199
  if (task.status === "done") {
149
200
  response.saved_path = task.savedPath;
150
- response.model = task.model;
201
+ }
202
+ if (task.effectivePrompt) {
203
+ response.effective_prompt = task.effectivePrompt;
151
204
  }
152
205
  if (task.status === "failed") {
153
206
  response.error = task.errorCode;
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
@@ -81,8 +81,7 @@ async function handleProviderError(res) {
81
81
  throw new AppError("E_PROVIDER_BAD_REQUEST", `Upstream error ${res.status}: ${text.slice(0, 200)}`);
82
82
  }
83
83
  async function generateOpenAIImage(config, opts) {
84
- const model = opts.model ?? config.model;
85
- const prompt = opts.negativePrompt ? `${opts.prompt}\n\nAvoid: ${opts.negativePrompt}` : opts.prompt;
84
+ const prompt = opts.prompt;
86
85
  const size = mapOpenAISize(opts.size, opts.aspectRatio);
87
86
  const hasReferenceImages = Boolean(opts.referenceImages?.length);
88
87
  const url = `${config.baseUrl}${hasReferenceImages ? "/v1/images/edits" : "/v1/images/generations"}`;
@@ -92,42 +91,40 @@ async function generateOpenAIImage(config, opts) {
92
91
  };
93
92
  if (hasReferenceImages) {
94
93
  const form = new FormData();
95
- form.set("model", model);
94
+ form.set("model", GPT_IMAGE_MODEL);
96
95
  form.set("prompt", prompt);
97
96
  form.set("size", size);
98
97
  if (opts.aspectRatio) {
99
98
  form.set("aspect_ratio", opts.aspectRatio);
100
99
  }
101
100
  for (const [index, imgPath] of opts.referenceImages.entries()) {
102
- const data = await resizeIfNeeded(imgPath);
101
+ const data = await resizeIfNeeded(imgPath, opts.referenceMaxSide);
103
102
  const bytes = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
104
- form.append("image", new Blob([bytes], { type: "image/png" }), `reference-${index}.png`);
103
+ form.append("image", new Blob([bytes], { type: "image/webp" }), `reference-${index}.webp`);
105
104
  }
106
105
  body = form;
107
106
  }
108
107
  else {
109
108
  headers = { ...headers, "Content-Type": "application/json" };
110
109
  body = JSON.stringify({
111
- model,
110
+ model: GPT_IMAGE_MODEL,
112
111
  prompt,
113
112
  size,
114
113
  ...(opts.aspectRatio ? { aspect_ratio: opts.aspectRatio } : {}),
115
114
  });
116
115
  }
116
+ // 启用 2 次重试,应对上游偶发的 EOF / 524(Cloudflare 100s 超时)。
117
+ // retries=0 时所有偶发失败一次过即死,指数退避代码是死代码。
117
118
  const res = await fetchWithRetry(url, {
118
119
  method: "POST",
119
120
  headers,
120
121
  body,
121
- }, config.timeoutMs);
122
+ }, config.timeoutMs, 2);
122
123
  if (!res.ok) {
123
124
  await handleProviderError(res);
124
125
  }
125
126
  return imageFromOpenAIResponse(res);
126
127
  }
127
128
  export async function generateImage(config, opts) {
128
- const model = opts.model ?? config.model;
129
- if (model !== GPT_IMAGE_MODEL) {
130
- throw new AppError("E_PROVIDER_BAD_REQUEST", `Unsupported model: ${model}`);
131
- }
132
129
  return generateOpenAIImage(config, opts);
133
130
  }
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,46 +1,46 @@
1
- {
2
- "name": "mcp-duojie-image",
3
- "version": "0.5.7",
4
- "description": "MCP server for image generation via duojie.games",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "bin": {
8
- "mcp-duojie-image": "dist/index.js"
9
- },
10
- "scripts": {
11
- "dev": "tsx src/index.ts",
12
- "build": "tsc",
13
- "start": "node dist/index.js",
14
- "test": "vitest run",
15
- "lint": "tsc --noEmit",
16
- "prepublishOnly": "npm run build"
17
- },
18
- "keywords": [
19
- "mcp",
20
- "image-generation",
21
- "gpt",
22
- "openai",
23
- "ai"
24
- ],
25
- "author": "",
26
- "license": "MIT",
27
- "dependencies": {
28
- "@modelcontextprotocol/sdk": "^1.12.0",
29
- "jimp": "^1.6.0",
30
- "node-notifier": "^10.0.1",
31
- "zod": "^3.23.8"
32
- },
33
- "devDependencies": {
34
- "@types/node": "^22.0.0",
35
- "@types/node-notifier": "^8.0.5",
36
- "tsx": "^4.19.0",
37
- "typescript": "^5.5.0",
38
- "vitest": "^2.0.0"
39
- },
40
- "engines": {
41
- "node": ">=18.0.0"
42
- },
43
- "files": [
44
- "dist"
45
- ]
46
- }
1
+ {
2
+ "name": "mcp-duojie-image",
3
+ "version": "0.6.0",
4
+ "description": "MCP server for image generation via duojie.games",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "mcp-duojie-image": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "dev": "tsx src/index.ts",
12
+ "build": "tsc",
13
+ "start": "node dist/index.js",
14
+ "test": "vitest run",
15
+ "lint": "tsc --noEmit",
16
+ "prepublishOnly": "npm run build"
17
+ },
18
+ "keywords": [
19
+ "mcp",
20
+ "image-generation",
21
+ "gpt",
22
+ "openai",
23
+ "ai"
24
+ ],
25
+ "author": "",
26
+ "license": "MIT",
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.12.0",
29
+ "node-notifier": "^10.0.1",
30
+ "sharp": "^0.35.3",
31
+ "zod": "^3.23.8"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.0.0",
35
+ "@types/node-notifier": "^8.0.5",
36
+ "tsx": "^4.19.0",
37
+ "typescript": "^5.5.0",
38
+ "vitest": "^2.0.0"
39
+ },
40
+ "engines": {
41
+ "node": ">=18.0.0"
42
+ },
43
+ "files": [
44
+ "dist"
45
+ ]
46
+ }