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 +29 -4
- package/dist/image-processing.js +84 -0
- package/dist/index.js +64 -7
- package/dist/prompt.js +87 -0
- package/dist/provider.js +27 -4
- package/dist/resizer.js +33 -11
- package/dist/saver.js +17 -4
- package/package.json +2 -2
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[] | - |
|
|
41
|
-
| `
|
|
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.
|
|
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
|
-
|
|
64
|
-
prompt:
|
|
65
|
-
referenceImages:
|
|
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
|
|
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:
|
|
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.
|
|
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/
|
|
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
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import { AppError } from "./errors.js";
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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) || `.${
|
|
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.
|
|
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": {
|