trace.moe-mcp 1.0.0 → 1.0.2
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 +1 -1
- package/dist/index.js +316 -0
- package/dist/src/api.js +109 -0
- package/dist/src/format.js +155 -0
- package/dist/src/image-processor.js +219 -0
- package/package.json +9 -7
- package/index.ts +0 -370
- package/src/api.ts +0 -192
- package/src/format.ts +0 -195
- package/src/image-processor.ts +0 -262
package/src/api.ts
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,195 +0,0 @@
|
|
|
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
|
-
}
|
package/src/image-processor.ts
DELETED
|
@@ -1,262 +0,0 @@
|
|
|
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
|
-
}
|