trace.moe-mcp 1.0.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/LICENSE +21 -0
- package/README.md +94 -0
- package/index.ts +370 -0
- package/package.json +51 -0
- package/src/api.ts +192 -0
- package/src/format.ts +195 -0
- package/src/image-processor.ts +262 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2016 soruly
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# trace.moe-mcp
|
|
2
|
+
|
|
3
|
+
[](https://github.com/soruly/trace.moe-mcp/blob/master/LICENSE)
|
|
4
|
+
[](https://github.com/soruly/trace.moe-mcp/actions)
|
|
5
|
+
[](https://www.npmjs.com/package/trace.moe-mcp)
|
|
6
|
+
[](https://discord.gg/K9jn6Kj)
|
|
7
|
+
|
|
8
|
+
Model Context Protocol (MCP) server for [trace.moe](https://trace.moe) anime scene search API.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- **Local Image Pre-processing & Vector Extraction**:
|
|
13
|
+
- Automatically loads and decodes images locally (JPEG, PNG, WebP, AVIF, etc.).
|
|
14
|
+
- Detects and crops black letterbox/pillarbox borders (`cutBorders`).
|
|
15
|
+
- Computes the 33-element MPEG-7 Color Layout Descriptor vector using 8×8 2D-DCT locally.
|
|
16
|
+
- Sends **only the compact 33-number vector** (~150 bytes JSON payload) to `api.trace.moe`, minimizing bandwidth and keeping image content private.
|
|
17
|
+
- **Multiple Image Sources**: Supports direct Image URLs, local file paths, base64 strings, or pre-computed vectors.
|
|
18
|
+
- **Anime Title & Metadata Search**: Search anime titles, romanized names, and synonyms via `/anilist`.
|
|
19
|
+
- **Account & Quota Status**: Check remaining quota, concurrency limits, and priority via `/me` resource and tool.
|
|
20
|
+
- **Guided AI Prompt Template**: `identify_anime_scene` prompt template for LLMs.
|
|
21
|
+
|
|
22
|
+
## Available Tools
|
|
23
|
+
|
|
24
|
+
| Tool | Description | Inputs |
|
|
25
|
+
| :--------------------------- | :---------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
26
|
+
| `search_anime_by_image_url` | Search anime scene by image URL (preprocessed locally into 33-element vector) | `url` (string, required)<br>`cutBorders` (boolean, default true)<br>`anilistInfo` (boolean, default true)<br>`anilistID` (number, optional) |
|
|
27
|
+
| `search_anime_by_image_file` | Search anime scene from local file path or base64 string | `filePath` (string, optional)<br>`imageBase64` (string, optional)<br>`cutBorders` (boolean, default true)<br>`anilistInfo` (boolean, default true)<br>`anilistID` (number, optional) |
|
|
28
|
+
| `search_anime_by_vector` | Search anime scene directly using a 33-element color layout vector | `vector` (array of 33 numbers, required)<br>`anilistInfo` (boolean, default true)<br>`anilistID` (number, optional) |
|
|
29
|
+
| `search_anime_by_name` | Search anime metadata and retrieve Anilist IDs by name/title | `query` (string, required) |
|
|
30
|
+
| `get_account_quota` | Check current daily search quota, concurrency limit, and priority | _(None)_ |
|
|
31
|
+
|
|
32
|
+
## Available Resources
|
|
33
|
+
|
|
34
|
+
- `tracemoe://me` - Current search quota, priority, concurrency, and 24h usage status in JSON format.
|
|
35
|
+
|
|
36
|
+
## Available Prompts
|
|
37
|
+
|
|
38
|
+
- `trace_moe` - Prompt template guiding the AI model on how to identify an anime screenshot.
|
|
39
|
+
|
|
40
|
+
## Environment Variables
|
|
41
|
+
|
|
42
|
+
- `TRACE_MOE_API_KEY`: Optional API key sent via `x-trace-key` for higher rate limits and daily quota.
|
|
43
|
+
- `TRACE_MOE_API_HOST`: Optional custom API endpoint (defaults to `https://api.trace.moe`).
|
|
44
|
+
|
|
45
|
+
## Installation & Running
|
|
46
|
+
|
|
47
|
+
### Option 1: Run with `npx` (No installation required)
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx -y trace.moe-mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Option 2: Run directly from source
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git clone https://github.com/soruly/trace.moe-mcp.git
|
|
57
|
+
cd trace.moe-mcp
|
|
58
|
+
npm install
|
|
59
|
+
node index.ts
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### MCP Client Configuration (Claude Desktop, Cursor, Antigravity, etc.)
|
|
63
|
+
|
|
64
|
+
#### Using `npx`:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"mcpServers": {
|
|
69
|
+
"trace_moe": {
|
|
70
|
+
"command": "npx",
|
|
71
|
+
"args": ["-y", "trace.moe-mcp"],
|
|
72
|
+
"env": {
|
|
73
|
+
"TRACE_MOE_API_KEY": "your_api_key_here"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
#### Using local repository:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"mcpServers": {
|
|
85
|
+
"trace_moe": {
|
|
86
|
+
"command": "node",
|
|
87
|
+
"args": ["/absolute/path/to/trace.moe-mcp/index.ts"],
|
|
88
|
+
"env": {
|
|
89
|
+
"TRACE_MOE_API_KEY": "your_api_key_here"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
import { defaultClient } from "./src/api.ts";
|
|
7
|
+
import {
|
|
8
|
+
formatAnilistSearchResultsMarkdown,
|
|
9
|
+
formatSearchResultsMarkdown,
|
|
10
|
+
formatUserQuotaMarkdown,
|
|
11
|
+
} from "./src/format.ts";
|
|
12
|
+
import { processImageToVector } from "./src/image-processor.ts";
|
|
13
|
+
|
|
14
|
+
const server = new McpServer({
|
|
15
|
+
name: "trace.moe-mcp",
|
|
16
|
+
version: "1.0.0",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Tool: Search Anime by Image URL (with local vector preprocessing)
|
|
20
|
+
server.registerTool(
|
|
21
|
+
"search_anime_by_image_url",
|
|
22
|
+
{
|
|
23
|
+
title: "Search Anime by Image URL",
|
|
24
|
+
description:
|
|
25
|
+
"Search anime scene by image URL. Pre-processes the image locally into a 33-element Color Layout Descriptor vector and sends only the vector to api.trace.moe.",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
url: z.url().describe("Direct HTTP/HTTPS URL of the anime screenshot"),
|
|
28
|
+
cutBorders: z
|
|
29
|
+
.boolean()
|
|
30
|
+
.optional()
|
|
31
|
+
.default(true)
|
|
32
|
+
.describe("Automatically crop black letterbox or pillarbox borders before searching"),
|
|
33
|
+
anilistInfo: z
|
|
34
|
+
.boolean()
|
|
35
|
+
.optional()
|
|
36
|
+
.default(true)
|
|
37
|
+
.describe("Include full anime titles and metadata"),
|
|
38
|
+
anilistID: z
|
|
39
|
+
.number()
|
|
40
|
+
.int()
|
|
41
|
+
.positive()
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("Optional Anilist ID to filter search results within a specific anime"),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
async ({ url, cutBorders, anilistInfo, anilistID }) => {
|
|
47
|
+
try {
|
|
48
|
+
const vector = await processImageToVector({ url }, cutBorders);
|
|
49
|
+
const searchResult = await defaultClient.searchByVector(vector, {
|
|
50
|
+
anilistInfo,
|
|
51
|
+
anilistID,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const markdown = formatSearchResultsMarkdown(searchResult);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
content: [
|
|
58
|
+
{
|
|
59
|
+
type: "text",
|
|
60
|
+
text: markdown,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: "text",
|
|
64
|
+
text: JSON.stringify(searchResult, null, 2),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return {
|
|
70
|
+
isError: true,
|
|
71
|
+
content: [
|
|
72
|
+
{
|
|
73
|
+
type: "text",
|
|
74
|
+
text: `Failed to search anime by image URL: ${error instanceof Error ? error.message : String(error)}`,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Tool: Search Anime by Local File or Base64 Image
|
|
83
|
+
server.registerTool(
|
|
84
|
+
"search_anime_by_image_file",
|
|
85
|
+
{
|
|
86
|
+
title: "Search Anime by Image File or Base64",
|
|
87
|
+
description:
|
|
88
|
+
"Search anime scene from a local file path or base64 encoded image. Pre-processes the image locally into a 33-element vector and sends only the vector to api.trace.moe.",
|
|
89
|
+
inputSchema: {
|
|
90
|
+
filePath: z
|
|
91
|
+
.string()
|
|
92
|
+
.optional()
|
|
93
|
+
.describe("Local absolute or relative filesystem path to the image file"),
|
|
94
|
+
imageBase64: z
|
|
95
|
+
.string()
|
|
96
|
+
.optional()
|
|
97
|
+
.describe("Base64-encoded image string (with or without data URI scheme prefix)"),
|
|
98
|
+
cutBorders: z
|
|
99
|
+
.boolean()
|
|
100
|
+
.optional()
|
|
101
|
+
.default(true)
|
|
102
|
+
.describe("Automatically crop black letterbox or pillarbox borders before searching"),
|
|
103
|
+
anilistInfo: z
|
|
104
|
+
.boolean()
|
|
105
|
+
.optional()
|
|
106
|
+
.default(true)
|
|
107
|
+
.describe("Include full anime titles and metadata"),
|
|
108
|
+
anilistID: z
|
|
109
|
+
.number()
|
|
110
|
+
.int()
|
|
111
|
+
.positive()
|
|
112
|
+
.optional()
|
|
113
|
+
.describe("Optional Anilist ID to filter search results within a specific anime"),
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
async ({ filePath, imageBase64, cutBorders, anilistInfo, anilistID }) => {
|
|
117
|
+
try {
|
|
118
|
+
if (!filePath && !imageBase64) {
|
|
119
|
+
throw new Error("Must provide either 'filePath' or 'imageBase64'.");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const vector = await processImageToVector({ filePath, imageBase64 }, cutBorders);
|
|
123
|
+
const searchResult = await defaultClient.searchByVector(vector, {
|
|
124
|
+
anilistInfo,
|
|
125
|
+
anilistID,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const markdown = formatSearchResultsMarkdown(searchResult);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
content: [
|
|
132
|
+
{
|
|
133
|
+
type: "text",
|
|
134
|
+
text: markdown,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
type: "text",
|
|
138
|
+
text: JSON.stringify(searchResult, null, 2),
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return {
|
|
144
|
+
isError: true,
|
|
145
|
+
content: [
|
|
146
|
+
{
|
|
147
|
+
type: "text",
|
|
148
|
+
text: `Failed to search anime by image file: ${error instanceof Error ? error.message : String(error)}`,
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Tool: Search Anime by 33-element Color Layout Vector
|
|
157
|
+
server.registerTool(
|
|
158
|
+
"search_anime_by_vector",
|
|
159
|
+
{
|
|
160
|
+
title: "Search Anime by Color Layout Vector",
|
|
161
|
+
description:
|
|
162
|
+
"Search anime scene directly using a 33-element MPEG-7 Color Layout Descriptor vector.",
|
|
163
|
+
inputSchema: {
|
|
164
|
+
vector: z
|
|
165
|
+
.array(z.number())
|
|
166
|
+
.length(33)
|
|
167
|
+
.describe("33-element integer vector representing MPEG-7 Color Layout Descriptor"),
|
|
168
|
+
anilistInfo: z
|
|
169
|
+
.boolean()
|
|
170
|
+
.optional()
|
|
171
|
+
.default(true)
|
|
172
|
+
.describe("Include full anime titles and metadata"),
|
|
173
|
+
anilistID: z
|
|
174
|
+
.number()
|
|
175
|
+
.int()
|
|
176
|
+
.positive()
|
|
177
|
+
.optional()
|
|
178
|
+
.describe("Optional Anilist ID to filter search results within a specific anime"),
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
async ({ vector, anilistInfo, anilistID }) => {
|
|
182
|
+
try {
|
|
183
|
+
const searchResult = await defaultClient.searchByVector(vector, {
|
|
184
|
+
anilistInfo,
|
|
185
|
+
anilistID,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const markdown = formatSearchResultsMarkdown(searchResult);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
content: [
|
|
192
|
+
{
|
|
193
|
+
type: "text",
|
|
194
|
+
text: markdown,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
type: "text",
|
|
198
|
+
text: JSON.stringify(searchResult, null, 2),
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
};
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return {
|
|
204
|
+
isError: true,
|
|
205
|
+
content: [
|
|
206
|
+
{
|
|
207
|
+
type: "text",
|
|
208
|
+
text: `Failed to search anime by vector: ${error instanceof Error ? error.message : String(error)}`,
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Tool: Search Anime by Name
|
|
217
|
+
server.registerTool(
|
|
218
|
+
"search_anime_by_name",
|
|
219
|
+
{
|
|
220
|
+
title: "Search Anime by Name",
|
|
221
|
+
description:
|
|
222
|
+
"Search anime titles, romanized names, and synonyms using trace.moe Anilist database to retrieve Anilist IDs and metadata.",
|
|
223
|
+
inputSchema: {
|
|
224
|
+
query: z
|
|
225
|
+
.string()
|
|
226
|
+
.min(1)
|
|
227
|
+
.describe("Anime name, Chinese name, Japanese name, or keyword to search"),
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
async ({ query }) => {
|
|
231
|
+
try {
|
|
232
|
+
const results = await defaultClient.searchAnilist(query);
|
|
233
|
+
const markdown = formatAnilistSearchResultsMarkdown(results, query);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
content: [
|
|
237
|
+
{
|
|
238
|
+
type: "text",
|
|
239
|
+
text: markdown,
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
type: "text",
|
|
243
|
+
text: JSON.stringify(results, null, 2),
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
};
|
|
247
|
+
} catch (error) {
|
|
248
|
+
return {
|
|
249
|
+
isError: true,
|
|
250
|
+
content: [
|
|
251
|
+
{
|
|
252
|
+
type: "text",
|
|
253
|
+
text: `Failed to search anime by name: ${error instanceof Error ? error.message : String(error)}`,
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// Tool: Get Account Quota & Concurrency
|
|
262
|
+
server.registerTool(
|
|
263
|
+
"get_account_quota",
|
|
264
|
+
{
|
|
265
|
+
title: "Get Account Quota & Concurrency",
|
|
266
|
+
description:
|
|
267
|
+
"Check remaining daily search quota, concurrency limit, and priority for the current IP / API key on trace.moe.",
|
|
268
|
+
inputSchema: {},
|
|
269
|
+
},
|
|
270
|
+
async () => {
|
|
271
|
+
try {
|
|
272
|
+
const user = await defaultClient.getMe();
|
|
273
|
+
const markdown = formatUserQuotaMarkdown(user);
|
|
274
|
+
return {
|
|
275
|
+
content: [
|
|
276
|
+
{
|
|
277
|
+
type: "text",
|
|
278
|
+
text: markdown,
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
type: "text",
|
|
282
|
+
text: JSON.stringify(user, null, 2),
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
};
|
|
286
|
+
} catch (error) {
|
|
287
|
+
return {
|
|
288
|
+
isError: true,
|
|
289
|
+
content: [
|
|
290
|
+
{
|
|
291
|
+
type: "text",
|
|
292
|
+
text: `Failed to get quota: ${error instanceof Error ? error.message : String(error)}`,
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Resource: tracemoe://me
|
|
301
|
+
server.registerResource(
|
|
302
|
+
"user-quota",
|
|
303
|
+
"tracemoe://me",
|
|
304
|
+
{
|
|
305
|
+
mimeType: "application/json",
|
|
306
|
+
description: "Current trace.moe search quota, limits, and usage",
|
|
307
|
+
},
|
|
308
|
+
async () => {
|
|
309
|
+
const user = await defaultClient.getMe();
|
|
310
|
+
return {
|
|
311
|
+
contents: [
|
|
312
|
+
{
|
|
313
|
+
uri: "tracemoe://me",
|
|
314
|
+
mimeType: "application/json",
|
|
315
|
+
text: JSON.stringify(user, null, 2),
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
};
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// Prompt: trace_moe
|
|
323
|
+
server.registerPrompt(
|
|
324
|
+
"trace_moe",
|
|
325
|
+
{
|
|
326
|
+
title: "Trace Anime Scene",
|
|
327
|
+
description:
|
|
328
|
+
"Guide the model on identifying an anime screenshot scene using trace.moe and presenting accurate information.",
|
|
329
|
+
argsSchema: {
|
|
330
|
+
imageUrl: z.string().optional().describe("URL of the anime scene image to identify"),
|
|
331
|
+
filePath: z
|
|
332
|
+
.string()
|
|
333
|
+
.optional()
|
|
334
|
+
.describe("Local file path of the anime scene image to identify"),
|
|
335
|
+
notes: z.string().optional().describe("Additional clues or context"),
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
({ imageUrl, filePath, notes }) => {
|
|
339
|
+
const promptText = `Please identify the anime scene from the provided image ${imageUrl ? `at ${imageUrl}` : ""}${filePath ? `(file: ${filePath})` : ""}.${notes ? ` Additional context: ${notes}` : ""}
|
|
340
|
+
|
|
341
|
+
Steps to follow:
|
|
342
|
+
1. Use the 'search_anime_by_image_url' or 'search_anime_by_image_file' tool to search trace.moe.
|
|
343
|
+
2. If similarity is >= 85%, report the matched Anime Title (English, Romaji, and Native Japanese), Episode number, and exact timestamp (e.g. 00:12:34).
|
|
344
|
+
3. Include the Anilist link and preview thumbnail / video clip URL for verification.
|
|
345
|
+
4. If similarity is low (< 80%), warn the user that the match might be uncertain.`;
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
messages: [
|
|
349
|
+
{
|
|
350
|
+
role: "user",
|
|
351
|
+
content: {
|
|
352
|
+
type: "text",
|
|
353
|
+
text: promptText,
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
};
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
// Start server using Stdio transport
|
|
362
|
+
async function main() {
|
|
363
|
+
const transport = new StdioServerTransport();
|
|
364
|
+
await server.connect(transport);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
main().catch((err) => {
|
|
368
|
+
console.error("Fatal error running trace.moe MCP server:", err);
|
|
369
|
+
process.exit(1);
|
|
370
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "trace.moe-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Model Context Protocol (MCP) server for trace.moe anime scene search",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"anime",
|
|
7
|
+
"mcp",
|
|
8
|
+
"trace.moe"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://github.com/soruly/trace.moe-mcp",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/soruly/trace.moe-mcp/issues"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "soruly",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/soruly/trace.moe-mcp.git"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"trace.moe-mcp": "./index.ts"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"index.ts",
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "index.ts",
|
|
29
|
+
"scripts": {
|
|
30
|
+
"start": "node index.ts",
|
|
31
|
+
"format": "oxfmt",
|
|
32
|
+
"lint": "oxlint",
|
|
33
|
+
"lint:fix": "oxlint --fix",
|
|
34
|
+
"test": "oxfmt --check && oxlint && node --test test/*.test.ts"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
38
|
+
"sharp": "^0.35.4",
|
|
39
|
+
"trace.moe-id": "^2.0.0",
|
|
40
|
+
"zod": "^4.6.2"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^26.5.1",
|
|
44
|
+
"@types/sharp": "^0.31.1",
|
|
45
|
+
"oxfmt": "^0.67.0",
|
|
46
|
+
"oxlint": "^1.82.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=24"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
export interface AnilistTitle {
|
|
2
|
+
native?: string | null;
|
|
3
|
+
romaji?: string | null;
|
|
4
|
+
english?: string | null;
|
|
5
|
+
chinese?: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface AnilistData {
|
|
9
|
+
id: number;
|
|
10
|
+
idMal?: number | null;
|
|
11
|
+
title: AnilistTitle;
|
|
12
|
+
synonyms?: string[];
|
|
13
|
+
synonyms_chinese?: string[];
|
|
14
|
+
isAdult?: boolean;
|
|
15
|
+
episodes?: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SearchResultItem {
|
|
19
|
+
anilist: number | AnilistData;
|
|
20
|
+
filename: string;
|
|
21
|
+
episode?: number | string | (number | string)[] | null;
|
|
22
|
+
episode_start?: number | null;
|
|
23
|
+
episode_end?: number | null;
|
|
24
|
+
from: number;
|
|
25
|
+
to: number;
|
|
26
|
+
similarity: number;
|
|
27
|
+
video: string;
|
|
28
|
+
image: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SearchResponse {
|
|
32
|
+
frameCount: number;
|
|
33
|
+
error?: string;
|
|
34
|
+
result: SearchResultItem[];
|
|
35
|
+
quota?: number;
|
|
36
|
+
quotaUsed?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface UserResponse {
|
|
40
|
+
id: string;
|
|
41
|
+
priority: number;
|
|
42
|
+
concurrency: number;
|
|
43
|
+
quota: number;
|
|
44
|
+
quotaUsed: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AnilistSearchResultItem {
|
|
48
|
+
id: number;
|
|
49
|
+
title: string;
|
|
50
|
+
similarity: number;
|
|
51
|
+
anilist: AnilistData;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class TraceMoeClient {
|
|
55
|
+
private baseUrl: string;
|
|
56
|
+
private apiKey: string;
|
|
57
|
+
|
|
58
|
+
constructor(options?: { baseUrl?: string; apiKey?: string }) {
|
|
59
|
+
this.baseUrl = (
|
|
60
|
+
options?.baseUrl ||
|
|
61
|
+
process.env.TRACE_MOE_API_HOST ||
|
|
62
|
+
"https://api.trace.moe"
|
|
63
|
+
).replace(/\/$/, "");
|
|
64
|
+
|
|
65
|
+
this.apiKey = options?.apiKey || process.env.TRACE_MOE_API_KEY || "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private getHeaders(): Record<string, string> {
|
|
69
|
+
const headers: Record<string, string> = {
|
|
70
|
+
"User-Agent": "trace.moe-mcp/1.0.0",
|
|
71
|
+
};
|
|
72
|
+
if (this.apiKey) {
|
|
73
|
+
headers["x-trace-key"] = this.apiKey;
|
|
74
|
+
}
|
|
75
|
+
return headers;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Search anime scene by sending a 33-element MPEG-7 Color Layout Descriptor vector.
|
|
80
|
+
*/
|
|
81
|
+
async searchByVector(
|
|
82
|
+
vector: number[],
|
|
83
|
+
options?: {
|
|
84
|
+
anilistInfo?: boolean;
|
|
85
|
+
anilistID?: number;
|
|
86
|
+
},
|
|
87
|
+
): Promise<SearchResponse> {
|
|
88
|
+
if (!Array.isArray(vector) || vector.length !== 33) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Invalid feature vector: expected 33 numbers, got ${Array.isArray(vector) ? vector.length : typeof vector}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const queryParams = new URLSearchParams();
|
|
95
|
+
if (options?.anilistInfo !== false) {
|
|
96
|
+
queryParams.set("anilistInfo", "2");
|
|
97
|
+
}
|
|
98
|
+
if (options?.anilistID !== undefined) {
|
|
99
|
+
queryParams.set("anilistID", String(options.anilistID));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const qs = queryParams.toString();
|
|
103
|
+
const url = `${this.baseUrl}/search${qs ? `?${qs}` : ""}`;
|
|
104
|
+
|
|
105
|
+
const headers = {
|
|
106
|
+
...this.getHeaders(),
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
let response: Response | undefined;
|
|
111
|
+
let retries = 3;
|
|
112
|
+
|
|
113
|
+
while (retries > 0) {
|
|
114
|
+
response = await fetch(url, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers,
|
|
117
|
+
body: JSON.stringify({ vector }),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (response.status !== 503 || retries === 1) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
retries--;
|
|
125
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!response) {
|
|
129
|
+
throw new Error("No response received from trace.moe API");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
const errorText = await response.text().catch(() => "");
|
|
134
|
+
let parsedError = errorText;
|
|
135
|
+
try {
|
|
136
|
+
const json = JSON.parse(errorText);
|
|
137
|
+
if (json.error) parsedError = json.error;
|
|
138
|
+
} catch {}
|
|
139
|
+
|
|
140
|
+
if (response.status === 402) {
|
|
141
|
+
throw new Error(`trace.moe search quota exceeded. ${parsedError}`);
|
|
142
|
+
}
|
|
143
|
+
if (response.status === 429) {
|
|
144
|
+
throw new Error(`trace.moe rate limit exceeded. Please try again later. ${parsedError}`);
|
|
145
|
+
}
|
|
146
|
+
if (response.status === 503) {
|
|
147
|
+
throw new Error(`trace.moe server is currently busy or overloaded. ${parsedError}`);
|
|
148
|
+
}
|
|
149
|
+
throw new Error(`trace.moe API error (${response.status}): ${parsedError}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (await response.json()) as SearchResponse;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Search anime by title/synonym using /anilist?q=...
|
|
157
|
+
*/
|
|
158
|
+
async searchAnilist(query: string): Promise<AnilistSearchResultItem[]> {
|
|
159
|
+
const url = `${this.baseUrl}/anilist?q=${encodeURIComponent(query)}`;
|
|
160
|
+
const response = await fetch(url, {
|
|
161
|
+
method: "GET",
|
|
162
|
+
headers: this.getHeaders(),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (!response.ok) {
|
|
166
|
+
const errorText = await response.text().catch(() => "");
|
|
167
|
+
throw new Error(`trace.moe anilist search failed (${response.status}): ${errorText}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return (await response.json()) as AnilistSearchResultItem[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get user quota, priority, concurrency, and usage info from /me
|
|
175
|
+
*/
|
|
176
|
+
async getMe(): Promise<UserResponse> {
|
|
177
|
+
const url = `${this.baseUrl}/me`;
|
|
178
|
+
const response = await fetch(url, {
|
|
179
|
+
method: "GET",
|
|
180
|
+
headers: this.getHeaders(),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
const errorText = await response.text().catch(() => "");
|
|
185
|
+
throw new Error(`trace.moe /me failed (${response.status}): ${errorText}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return (await response.json()) as UserResponse;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const defaultClient = new TraceMoeClient();
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnilistData,
|
|
3
|
+
type AnilistSearchResultItem,
|
|
4
|
+
type SearchResponse,
|
|
5
|
+
type SearchResultItem,
|
|
6
|
+
type UserResponse,
|
|
7
|
+
} from "./api.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Formats seconds into MM:SS or HH:MM:SS format.
|
|
11
|
+
*/
|
|
12
|
+
export function formatTime(seconds: number): string {
|
|
13
|
+
if (isNaN(seconds) || seconds < 0) return "00:00";
|
|
14
|
+
const sec = Math.floor(seconds);
|
|
15
|
+
const h = Math.floor(sec / 3600);
|
|
16
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
17
|
+
const s = sec % 60;
|
|
18
|
+
|
|
19
|
+
const mm = String(m).padStart(2, "0");
|
|
20
|
+
const ss = String(s).padStart(2, "0");
|
|
21
|
+
|
|
22
|
+
if (h > 0) {
|
|
23
|
+
const hh = String(h).padStart(2, "0");
|
|
24
|
+
return `${hh}:${mm}:${ss}`;
|
|
25
|
+
}
|
|
26
|
+
return `${mm}:${ss}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Formats a similarity float (0.0 - 1.0) into a percentage string (e.g. "98.5%").
|
|
31
|
+
*/
|
|
32
|
+
export function formatSimilarity(similarity: number): string {
|
|
33
|
+
return `${(similarity * 100).toFixed(1)}%`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Extracts anime titles for all languages (native Japanese, romaji, English, Chinese).
|
|
38
|
+
* Returns primary English/Romaji title and the full list of titles across all languages.
|
|
39
|
+
*/
|
|
40
|
+
export function getAnimeTitles(anilist: number | AnilistData): {
|
|
41
|
+
primary: string;
|
|
42
|
+
titles: string[];
|
|
43
|
+
id: number;
|
|
44
|
+
} {
|
|
45
|
+
if (typeof anilist === "number") {
|
|
46
|
+
return { primary: `Anilist ID ${anilist}`, titles: [`Anilist ID ${anilist}`], id: anilist };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { chinese, english, native, romaji } = anilist.title || {};
|
|
50
|
+
const titles: string[] = [];
|
|
51
|
+
|
|
52
|
+
if (native && !titles.includes(native)) titles.push(native);
|
|
53
|
+
if (romaji && !titles.includes(romaji)) titles.push(romaji);
|
|
54
|
+
if (english && !titles.includes(english)) titles.push(english);
|
|
55
|
+
if (chinese && !titles.includes(chinese)) titles.push(chinese);
|
|
56
|
+
|
|
57
|
+
if (titles.length === 0) {
|
|
58
|
+
titles.push(`Anilist ID ${anilist.id}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const primary = english || romaji || native || chinese || `Anilist ID ${anilist.id}`;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
primary,
|
|
65
|
+
titles,
|
|
66
|
+
id: anilist.id,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Formats episode information into a readable string (e.g. "1/12", "1-2/12", "1", "1-2").
|
|
72
|
+
* Prefers episode_start and episode_end (aligned with Anilist episode count) over filename episode.
|
|
73
|
+
* Returns null if episode is unknown.
|
|
74
|
+
*/
|
|
75
|
+
export function formatEpisode(item: SearchResultItem): string | null {
|
|
76
|
+
let epText: string | null = null;
|
|
77
|
+
|
|
78
|
+
if (item.episode_start !== undefined && item.episode_start !== null) {
|
|
79
|
+
if (
|
|
80
|
+
item.episode_end !== undefined &&
|
|
81
|
+
item.episode_end !== null &&
|
|
82
|
+
item.episode_end !== item.episode_start
|
|
83
|
+
) {
|
|
84
|
+
epText = `${item.episode_start}-${item.episode_end}`;
|
|
85
|
+
} else {
|
|
86
|
+
epText = String(item.episode_start);
|
|
87
|
+
}
|
|
88
|
+
} else if (item.episode !== undefined && item.episode !== null && item.episode !== "") {
|
|
89
|
+
if (Array.isArray(item.episode)) {
|
|
90
|
+
epText = item.episode.join(", ");
|
|
91
|
+
} else {
|
|
92
|
+
epText = String(item.episode);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!epText) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// If total episode count from Anilist is available, append /total (e.g. "1/12")
|
|
101
|
+
if (typeof item.anilist === "object" && item.anilist !== null && item.anilist.episodes) {
|
|
102
|
+
epText = `${epText}/${item.anilist.episodes}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return epText;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Formats a list of search results into markdown.
|
|
110
|
+
*/
|
|
111
|
+
export function formatSearchResultsMarkdown(response: SearchResponse): string {
|
|
112
|
+
if (!response.result || response.result.length === 0) {
|
|
113
|
+
return "No matching anime scene found on trace.moe.";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const lines: string[] = [];
|
|
117
|
+
lines.push(
|
|
118
|
+
`### trace.moe Search Results (Found ${response.result.length} matches, compared ${response.frameCount.toLocaleString()} frames)`,
|
|
119
|
+
);
|
|
120
|
+
lines.push("");
|
|
121
|
+
|
|
122
|
+
response.result.slice(0, 5).forEach((item, index) => {
|
|
123
|
+
const titleInfo = getAnimeTitles(item.anilist);
|
|
124
|
+
const ep = formatEpisode(item);
|
|
125
|
+
const timeRange = `${formatTime(item.from)} - ${formatTime(item.to)}`;
|
|
126
|
+
const sim = formatSimilarity(item.similarity);
|
|
127
|
+
const warningEmoji = item.similarity < 0.8 ? " ⚠️" : "";
|
|
128
|
+
|
|
129
|
+
lines.push(`#### ${index + 1}. ${titleInfo.primary}${warningEmoji} (${sim} similarity)`);
|
|
130
|
+
for (const t of titleInfo.titles) {
|
|
131
|
+
if (t !== titleInfo.primary) {
|
|
132
|
+
lines.push(`- ${t}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (ep) {
|
|
136
|
+
lines.push(`- **Episode**: ${ep}`);
|
|
137
|
+
}
|
|
138
|
+
lines.push(`- **Timestamp**: \`${timeRange}\``);
|
|
139
|
+
lines.push(
|
|
140
|
+
`- **Anilist**: [https://anilist.co/anime/${titleInfo.id}](https://anilist.co/anime/${titleInfo.id})`,
|
|
141
|
+
);
|
|
142
|
+
lines.push(`- **Preview Image**: ${item.image}`);
|
|
143
|
+
lines.push(`- **Preview Video**: ${item.video}`);
|
|
144
|
+
lines.push("");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return lines.join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Formats anime name search results into markdown.
|
|
152
|
+
*/
|
|
153
|
+
export function formatAnilistSearchResultsMarkdown(
|
|
154
|
+
results: AnilistSearchResultItem[],
|
|
155
|
+
query: string,
|
|
156
|
+
): string {
|
|
157
|
+
if (!results || results.length === 0) {
|
|
158
|
+
return `No anime found matching query: "${query}"`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const lines = [`### Anime Search Results for "${query}" (Found ${results.length} matches):`, ""];
|
|
162
|
+
|
|
163
|
+
results.slice(0, 10).forEach((item, idx) => {
|
|
164
|
+
const titleInfo = getAnimeTitles(item.anilist);
|
|
165
|
+
const sim = (item.similarity * 100).toFixed(0);
|
|
166
|
+
lines.push(
|
|
167
|
+
`#### ${idx + 1}. ${titleInfo.primary} (Match: ${sim}%, Anilist ID: \`${item.id}\`)`,
|
|
168
|
+
);
|
|
169
|
+
for (const t of titleInfo.titles) {
|
|
170
|
+
if (t !== titleInfo.primary) {
|
|
171
|
+
lines.push(`- ${t}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
lines.push(
|
|
175
|
+
`- **Anilist**: [https://anilist.co/anime/${item.id}](https://anilist.co/anime/${item.id})`,
|
|
176
|
+
);
|
|
177
|
+
lines.push("");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return lines.join("\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Formats user quota information into markdown.
|
|
185
|
+
*/
|
|
186
|
+
export function formatUserQuotaMarkdown(user: UserResponse): string {
|
|
187
|
+
const remaining = Math.max(0, user.quota - user.quotaUsed);
|
|
188
|
+
return `### trace.moe Account Quota & Status
|
|
189
|
+
- **ID**: \`${user.id}\`
|
|
190
|
+
- **Remaining Daily Quota**: **${remaining.toLocaleString()}** / ${user.quota.toLocaleString()}
|
|
191
|
+
- **Searches Used (last 24h)**: ${user.quotaUsed.toLocaleString()}
|
|
192
|
+
- **Concurrency Limit**: ${user.concurrency}
|
|
193
|
+
- **Search Queue Priority**: ${user.priority}
|
|
194
|
+
`;
|
|
195
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import sharp from "sharp";
|
|
5
|
+
import { ColorLayout } from "trace.moe-id";
|
|
6
|
+
|
|
7
|
+
sharp.cache(false);
|
|
8
|
+
sharp.concurrency(1);
|
|
9
|
+
|
|
10
|
+
function getVideoFrameRect(
|
|
11
|
+
data: Buffer,
|
|
12
|
+
width: number,
|
|
13
|
+
height: number,
|
|
14
|
+
channels = 3,
|
|
15
|
+
colorTolerance = 10,
|
|
16
|
+
) {
|
|
17
|
+
function isDark(x: number, y: number) {
|
|
18
|
+
const i = (y * width + x) * channels;
|
|
19
|
+
return (
|
|
20
|
+
data[i] <= colorTolerance && data[i + 1] <= colorTolerance && data[i + 2] <= colorTolerance
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isRowDark(y: number) {
|
|
25
|
+
let darkPixelCount = 0;
|
|
26
|
+
for (let x = 0; x < width; x++) {
|
|
27
|
+
if (isDark(x, y)) darkPixelCount++;
|
|
28
|
+
}
|
|
29
|
+
return darkPixelCount > width * 0.95;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isColDark(x: number) {
|
|
33
|
+
let darkPixelCount = 0;
|
|
34
|
+
for (let y = 0; y < height; y++) {
|
|
35
|
+
if (isDark(x, y)) darkPixelCount++;
|
|
36
|
+
}
|
|
37
|
+
return darkPixelCount > height * 0.95;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let top: number, bottom: number, left: number, right: number;
|
|
41
|
+
|
|
42
|
+
const centerY = Math.floor(height / 2);
|
|
43
|
+
const centerX = Math.floor(width / 2);
|
|
44
|
+
|
|
45
|
+
if (!isDark(centerX, centerY)) {
|
|
46
|
+
top = centerY;
|
|
47
|
+
bottom = centerY;
|
|
48
|
+
left = centerX;
|
|
49
|
+
right = centerX;
|
|
50
|
+
while (top > 0 && !isRowDark(top - 1)) top--;
|
|
51
|
+
while (bottom < height - 1 && !isRowDark(bottom + 1)) bottom++;
|
|
52
|
+
while (left > 0 && !isColDark(left - 1)) left--;
|
|
53
|
+
while (right < width - 1 && !isColDark(right + 1)) right++;
|
|
54
|
+
} else {
|
|
55
|
+
top = 0;
|
|
56
|
+
bottom = height - 1;
|
|
57
|
+
left = 0;
|
|
58
|
+
right = width - 1;
|
|
59
|
+
while (top < height && isRowDark(top)) top++;
|
|
60
|
+
while (bottom > top && isRowDark(bottom)) bottom--;
|
|
61
|
+
while (left < width && isColDark(left)) left++;
|
|
62
|
+
while (right > left && isColDark(right)) right--;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
x: left,
|
|
67
|
+
y: top,
|
|
68
|
+
width: Math.max(1, right - left + 1),
|
|
69
|
+
height: Math.max(1, bottom - top + 1),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getNearestAspectRatio(
|
|
74
|
+
width: number,
|
|
75
|
+
height: number,
|
|
76
|
+
targetAspectRatios: number[],
|
|
77
|
+
threshold = 0.05,
|
|
78
|
+
) {
|
|
79
|
+
const aspectRatio = width / height;
|
|
80
|
+
let bestRatio = null;
|
|
81
|
+
let minDiff = Infinity;
|
|
82
|
+
|
|
83
|
+
for (const targetRatio of targetAspectRatios) {
|
|
84
|
+
const diff = Math.abs(aspectRatio - targetRatio);
|
|
85
|
+
if (diff < minDiff) {
|
|
86
|
+
minDiff = diff;
|
|
87
|
+
bestRatio = targetRatio;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (minDiff <= threshold) {
|
|
92
|
+
return bestRatio;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function snapRectToNearestAspectRatio(
|
|
98
|
+
rect: { x: number; y: number; width: number; height: number },
|
|
99
|
+
maxW: number,
|
|
100
|
+
maxH: number,
|
|
101
|
+
) {
|
|
102
|
+
const targetRatios = [4 / 3, 16 / 9, 21 / 9];
|
|
103
|
+
const currentRatio = rect.width / (rect.height || 1);
|
|
104
|
+
let R = targetRatios[0];
|
|
105
|
+
let minDiff = Math.abs(currentRatio - R);
|
|
106
|
+
for (let i = 1; i < targetRatios.length; i++) {
|
|
107
|
+
const diff = Math.abs(currentRatio - targetRatios[i]);
|
|
108
|
+
if (diff < minDiff) {
|
|
109
|
+
minDiff = diff;
|
|
110
|
+
R = targetRatios[i];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let w = rect.width;
|
|
115
|
+
let h = rect.height;
|
|
116
|
+
if (w / R > h) {
|
|
117
|
+
w = h * R;
|
|
118
|
+
} else {
|
|
119
|
+
h = w / R;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (w < 10) w = 10;
|
|
123
|
+
if (h < 10) h = 10;
|
|
124
|
+
|
|
125
|
+
const cx = rect.x + rect.width / 2;
|
|
126
|
+
const cy = rect.y + rect.height / 2;
|
|
127
|
+
let x = cx - w / 2;
|
|
128
|
+
let y = cy - h / 2;
|
|
129
|
+
|
|
130
|
+
if (x < 0) x = 0;
|
|
131
|
+
if (y < 0) y = 0;
|
|
132
|
+
if (x + w > maxW) {
|
|
133
|
+
x = maxW - w;
|
|
134
|
+
if (x < 0) {
|
|
135
|
+
x = 0;
|
|
136
|
+
w = maxW;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (y + h > maxH) {
|
|
140
|
+
y = maxH - h;
|
|
141
|
+
if (y < 0) {
|
|
142
|
+
y = 0;
|
|
143
|
+
h = maxH;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
x: Math.round(x),
|
|
149
|
+
y: Math.round(y),
|
|
150
|
+
width: Math.round(w),
|
|
151
|
+
height: Math.round(h),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Resizes and optionally crops black borders from an image buffer,
|
|
157
|
+
* returning raw RGB pixel data and dimensions.
|
|
158
|
+
*/
|
|
159
|
+
export async function resizeAndCropImage(
|
|
160
|
+
imageBuffer: Buffer,
|
|
161
|
+
cutBorders: boolean = true,
|
|
162
|
+
): Promise<{ data: Buffer; width: number; height: number }> {
|
|
163
|
+
const resized = await sharp(imageBuffer)
|
|
164
|
+
.resize({ width: 320, height: 320, fit: "inside" })
|
|
165
|
+
.toBuffer();
|
|
166
|
+
|
|
167
|
+
let cropped = sharp(resized);
|
|
168
|
+
|
|
169
|
+
if (cutBorders) {
|
|
170
|
+
try {
|
|
171
|
+
const { data, info } = await sharp(resized)
|
|
172
|
+
.removeAlpha()
|
|
173
|
+
.raw()
|
|
174
|
+
.toBuffer({ resolveWithObject: true });
|
|
175
|
+
|
|
176
|
+
const targetRatios = [4 / 3, 16 / 9, 21 / 9];
|
|
177
|
+
const matchedRatio = getNearestAspectRatio(info.width, info.height, targetRatios);
|
|
178
|
+
if (matchedRatio === null) {
|
|
179
|
+
const detected = getVideoFrameRect(data, info.width, info.height, 3, 10);
|
|
180
|
+
const snapped = snapRectToNearestAspectRatio(detected, info.width, info.height);
|
|
181
|
+
cropped = sharp(resized).extract({
|
|
182
|
+
left: snapped.x,
|
|
183
|
+
top: snapped.y,
|
|
184
|
+
width: snapped.width,
|
|
185
|
+
height: snapped.height,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// If border trimming fails, fallback to uncropped resized image
|
|
190
|
+
cropped = sharp(resized);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const { data, info } = await cropped
|
|
195
|
+
.flatten({ background: "#000000" })
|
|
196
|
+
.removeAlpha()
|
|
197
|
+
.raw()
|
|
198
|
+
.toBuffer({ resolveWithObject: true });
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
data,
|
|
202
|
+
width: info.width,
|
|
203
|
+
height: info.height,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Extracts the 33-element MPEG-7 Color Layout Vector from an image buffer.
|
|
209
|
+
*/
|
|
210
|
+
export async function extractVectorFromBuffer(
|
|
211
|
+
imageBuffer: Buffer,
|
|
212
|
+
cutBorders: boolean = true,
|
|
213
|
+
): Promise<number[]> {
|
|
214
|
+
const { data, width, height } = await resizeAndCropImage(imageBuffer, cutBorders);
|
|
215
|
+
return ColorLayout.extract({ data, width, height, channels: 3 });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Resolves an image input (URL, local file path, or Base64 string) to a Buffer.
|
|
220
|
+
*/
|
|
221
|
+
export async function resolveImageInputToBuffer(input: {
|
|
222
|
+
url?: string;
|
|
223
|
+
filePath?: string;
|
|
224
|
+
imageBase64?: string;
|
|
225
|
+
}): Promise<Buffer> {
|
|
226
|
+
if (input.imageBase64) {
|
|
227
|
+
// Strip data URL prefix if present (e.g. data:image/jpeg;base64,...)
|
|
228
|
+
const cleanBase64 = input.imageBase64.replace(/^data:image\/[a-z]+;base64,/, "");
|
|
229
|
+
return Buffer.from(cleanBase64, "base64");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (input.filePath) {
|
|
233
|
+
const resolvedPath = path.resolve(input.filePath);
|
|
234
|
+
return await fs.readFile(resolvedPath);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (input.url) {
|
|
238
|
+
const response = await fetch(input.url, {
|
|
239
|
+
headers: {
|
|
240
|
+
"User-Agent": "trace.moe-mcp/1.0.0",
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
throw new Error(`Failed to fetch image from URL: ${response.status} ${response.statusText}`);
|
|
245
|
+
}
|
|
246
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
247
|
+
return Buffer.from(arrayBuffer);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
throw new Error("No image source provided. Must provide url, filePath, or imageBase64.");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* High-level helper to process an image input directly into a 33-element vector.
|
|
255
|
+
*/
|
|
256
|
+
export async function processImageToVector(
|
|
257
|
+
input: { url?: string; filePath?: string; imageBase64?: string },
|
|
258
|
+
cutBorders: boolean = true,
|
|
259
|
+
): Promise<number[]> {
|
|
260
|
+
const buffer = await resolveImageInputToBuffer(input);
|
|
261
|
+
return extractVectorFromBuffer(buffer, cutBorders);
|
|
262
|
+
}
|