opencode-pollinations-plugin 6.1.0-beta.9 → 6.2.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.de.md +130 -0
- package/README.es.md +130 -0
- package/README.fr.md +130 -0
- package/README.it.md +130 -0
- package/README.md +87 -73
- package/dist/index.js +52 -161
- package/dist/locales/de.json +374 -0
- package/dist/locales/en.json +373 -0
- package/dist/locales/es.json +374 -0
- package/dist/locales/fr.json +373 -0
- package/dist/locales/index.d.ts +1 -0
- package/dist/locales/index.js +37 -0
- package/dist/locales/it.json +374 -0
- package/dist/server/commands.d.ts +6 -0
- package/dist/server/commands.js +394 -125
- package/dist/server/config.d.ts +34 -23
- package/dist/server/config.js +200 -108
- package/dist/server/connect-response.d.ts +2 -0
- package/dist/server/connect-response.js +59 -0
- package/dist/server/generate-config.d.ts +3 -30
- package/dist/server/generate-config.js +164 -106
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +124 -149
- package/dist/server/logger.d.ts +8 -0
- package/dist/server/logger.js +38 -0
- package/dist/server/models/cache.d.ts +35 -0
- package/dist/server/models/cache.js +160 -0
- package/dist/server/models/fetcher.d.ts +18 -0
- package/dist/server/models/fetcher.js +194 -0
- package/dist/server/models/index.d.ts +6 -0
- package/dist/server/models/index.js +5 -0
- package/dist/server/models/manual.d.ts +15 -0
- package/dist/server/models/manual.js +92 -0
- package/dist/server/models/types.d.ts +55 -0
- package/dist/server/models/types.js +7 -0
- package/dist/server/models/worker.d.ts +22 -0
- package/dist/server/models/worker.js +174 -0
- package/dist/server/pollinations-api.d.ts +11 -0
- package/dist/server/pollinations-api.js +21 -8
- package/dist/server/proxy.js +222 -307
- package/dist/server/quota.d.ts +2 -0
- package/dist/server/quota.js +89 -86
- package/dist/server/scripts/pollinations_pricing.d.ts +8 -0
- package/dist/server/scripts/pollinations_pricing.js +246 -0
- package/dist/server/scripts/test_cost_endpoints.d.ts +1 -0
- package/dist/server/scripts/test_cost_endpoints.js +61 -0
- package/dist/server/scripts/test_dynamic_pricing.d.ts +1 -0
- package/dist/server/scripts/test_dynamic_pricing.js +39 -0
- package/dist/server/scripts/test_freetier_audit.d.ts +11 -0
- package/dist/server/scripts/test_freetier_audit.js +215 -0
- package/dist/server/scripts/test_parallel_cost.d.ts +1 -0
- package/dist/server/scripts/test_parallel_cost.js +104 -0
- package/dist/server/toast.d.ts +7 -1
- package/dist/server/toast.js +43 -10
- package/dist/tools/design/gen_diagram.d.ts +2 -0
- package/dist/tools/design/gen_diagram.js +94 -0
- package/dist/tools/design/gen_palette.d.ts +2 -0
- package/dist/tools/design/gen_palette.js +182 -0
- package/dist/tools/design/gen_qrcode.d.ts +2 -0
- package/dist/tools/design/gen_qrcode.js +50 -0
- package/dist/tools/ffmpeg.d.ts +24 -0
- package/dist/tools/ffmpeg.js +54 -0
- package/dist/tools/index.d.ts +25 -0
- package/dist/tools/index.js +86 -0
- package/dist/tools/pollinations/beta_discovery.d.ts +9 -0
- package/dist/tools/pollinations/beta_discovery.js +201 -0
- package/dist/tools/pollinations/cost-guard.d.ts +38 -0
- package/dist/tools/pollinations/cost-guard.js +136 -0
- package/dist/tools/pollinations/deepsearch.d.ts +7 -0
- package/dist/tools/pollinations/deepsearch.js +80 -0
- package/dist/tools/pollinations/gen_audio.d.ts +18 -0
- package/dist/tools/pollinations/gen_audio.js +220 -0
- package/dist/tools/pollinations/gen_image.d.ts +11 -0
- package/dist/tools/pollinations/gen_image.js +211 -0
- package/dist/tools/pollinations/gen_music.d.ts +14 -0
- package/dist/tools/pollinations/gen_music.js +157 -0
- package/dist/tools/pollinations/gen_video.d.ts +16 -0
- package/dist/tools/pollinations/gen_video.js +249 -0
- package/dist/tools/pollinations/polli_config.d.ts +2 -0
- package/dist/tools/pollinations/polli_config.js +95 -0
- package/dist/tools/pollinations/polli_gen_confirm.d.ts +2 -0
- package/dist/tools/pollinations/polli_gen_confirm.js +48 -0
- package/dist/tools/pollinations/polli_status.d.ts +2 -0
- package/dist/tools/pollinations/polli_status.js +31 -0
- package/dist/tools/pollinations/polli_web_search.d.ts +15 -0
- package/dist/tools/pollinations/polli_web_search.js +126 -0
- package/dist/tools/pollinations/search_crawl_scrape.d.ts +7 -0
- package/dist/tools/pollinations/search_crawl_scrape.js +85 -0
- package/dist/tools/pollinations/shared.d.ts +181 -0
- package/dist/tools/pollinations/shared.js +758 -0
- package/dist/tools/pollinations/test_estimators.d.ts +1 -0
- package/dist/tools/pollinations/test_estimators.js +22 -0
- package/dist/tools/pollinations/transcribe_audio.d.ts +13 -0
- package/dist/tools/pollinations/transcribe_audio.js +171 -0
- package/dist/tools/power/extract_audio.d.ts +2 -0
- package/dist/tools/power/extract_audio.js +179 -0
- package/dist/tools/power/extract_frames.d.ts +2 -0
- package/dist/tools/power/extract_frames.js +237 -0
- package/dist/tools/power/file_to_url.d.ts +2 -0
- package/dist/tools/power/file_to_url.js +217 -0
- package/dist/tools/power/remove_background.d.ts +2 -0
- package/dist/tools/power/remove_background.js +404 -0
- package/dist/tools/power/rmbg_keys.d.ts +2 -0
- package/dist/tools/power/rmbg_keys.js +79 -0
- package/dist/tools/shared.d.ts +30 -0
- package/dist/tools/shared.js +80 -0
- package/package.json +10 -4
- package/dist/server/models-seed.d.ts +0 -18
- package/dist/server/models-seed.js +0 -55
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
2
|
+
import * as https from 'https';
|
|
3
|
+
import * as http from 'http';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
const PROVIDERS = [
|
|
7
|
+
// Provider 1: Litterbox (catbox.moe) — fiable, anonyme, testé OK
|
|
8
|
+
{
|
|
9
|
+
name: 'litterbox.catbox.moe',
|
|
10
|
+
maxSize: 200 * 1024 * 1024, // 200MB
|
|
11
|
+
expiry: '1h-72h',
|
|
12
|
+
upload: (filePath, fileName, fileData, mimeType, expiry) => httpUpload({
|
|
13
|
+
url: 'https://litterbox.catbox.moe/resources/internals/api.php',
|
|
14
|
+
fields: { reqtype: 'fileupload', time: expiry },
|
|
15
|
+
fileField: 'fileToUpload',
|
|
16
|
+
fileName, fileData, mimeType,
|
|
17
|
+
parseResponse: (body) => {
|
|
18
|
+
const trimmed = body.trim();
|
|
19
|
+
if (trimmed.startsWith('https://'))
|
|
20
|
+
return trimmed;
|
|
21
|
+
throw new Error(`Réponse inattendue: ${trimmed.substring(0, 100)}`);
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
},
|
|
25
|
+
// Provider 2: tmpfile.link — CDN rapide, 100MB max, 7j
|
|
26
|
+
{
|
|
27
|
+
name: 'tmpfile.link',
|
|
28
|
+
maxSize: 100 * 1024 * 1024, // 100MB
|
|
29
|
+
expiry: '7 jours',
|
|
30
|
+
upload: (filePath, fileName, fileData, mimeType) => httpUpload({
|
|
31
|
+
url: 'https://tmpfile.link/api/upload',
|
|
32
|
+
fields: {},
|
|
33
|
+
fileField: 'file',
|
|
34
|
+
fileName, fileData, mimeType,
|
|
35
|
+
parseResponse: (body) => {
|
|
36
|
+
const json = JSON.parse(body);
|
|
37
|
+
if (json.downloadLink)
|
|
38
|
+
return json.downloadLink;
|
|
39
|
+
throw new Error(`Pas de downloadLink: ${body.substring(0, 100)}`);
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
// Provider 3: file.io — auto-destruction après 1er téléchargement
|
|
44
|
+
{
|
|
45
|
+
name: 'file.io',
|
|
46
|
+
maxSize: 2 * 1024 * 1024 * 1024, // 2GB
|
|
47
|
+
expiry: '14 jours (auto-détruit)',
|
|
48
|
+
upload: (filePath, fileName, fileData, mimeType) => httpUpload({
|
|
49
|
+
url: 'https://file.io',
|
|
50
|
+
fields: { expires: '14d' },
|
|
51
|
+
fileField: 'file',
|
|
52
|
+
fileName, fileData, mimeType,
|
|
53
|
+
parseResponse: (body) => {
|
|
54
|
+
const json = JSON.parse(body);
|
|
55
|
+
if (json.success && json.link)
|
|
56
|
+
return json.link;
|
|
57
|
+
throw new Error(`file.io erreur: ${json.message || body.substring(0, 100)}`);
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
function httpUpload(opts) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const boundary = `----FormBoundary${Date.now()}${Math.random().toString(36).slice(2)}`;
|
|
65
|
+
const parts = [];
|
|
66
|
+
// Text fields
|
|
67
|
+
for (const [key, value] of Object.entries(opts.fields)) {
|
|
68
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`));
|
|
69
|
+
}
|
|
70
|
+
// File field
|
|
71
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${opts.fileField}"; filename="${opts.fileName}"\r\nContent-Type: ${opts.mimeType}\r\n\r\n`));
|
|
72
|
+
parts.push(opts.fileData);
|
|
73
|
+
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
|
74
|
+
const body = Buffer.concat(parts);
|
|
75
|
+
const url = new URL(opts.url);
|
|
76
|
+
const isHttps = url.protocol === 'https:';
|
|
77
|
+
const mod = isHttps ? https : http;
|
|
78
|
+
const req = mod.request({
|
|
79
|
+
hostname: url.hostname,
|
|
80
|
+
path: url.pathname,
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: {
|
|
83
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
84
|
+
'Content-Length': body.length,
|
|
85
|
+
'User-Agent': 'Mozilla/5.0 (compatible; OpenCode-Plugin/6.0)',
|
|
86
|
+
'Accept': 'application/json, text/plain, */*',
|
|
87
|
+
},
|
|
88
|
+
}, (res) => {
|
|
89
|
+
// Follow redirects (301, 302, 307)
|
|
90
|
+
if (res.statusCode && [301, 302, 307].includes(res.statusCode) && res.headers.location) {
|
|
91
|
+
httpUpload({ ...opts, url: res.headers.location }).then(resolve).catch(reject);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
let data = '';
|
|
95
|
+
res.on('data', (chunk) => data += chunk);
|
|
96
|
+
res.on('end', () => {
|
|
97
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
98
|
+
try {
|
|
99
|
+
resolve(opts.parseResponse(data));
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
reject(new Error(`Parse error: ${err.message}`));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
reject(new Error(`HTTP ${res.statusCode}: ${data.substring(0, 200)}`));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
req.on('error', (err) => reject(new Error(`Réseau: ${err.message}`)));
|
|
111
|
+
req.setTimeout(45000, () => {
|
|
112
|
+
req.destroy();
|
|
113
|
+
reject(new Error('Timeout (45s)'));
|
|
114
|
+
});
|
|
115
|
+
req.write(body);
|
|
116
|
+
req.end();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// ─── Upload with Cascade Fallback ───────────────────────────────────────────
|
|
120
|
+
async function uploadWithFallback(filePath, fileSize, expiry) {
|
|
121
|
+
const fileName = path.basename(filePath);
|
|
122
|
+
const fileData = fs.readFileSync(filePath);
|
|
123
|
+
const mimeType = getMimeType(fileName);
|
|
124
|
+
const attempts = [];
|
|
125
|
+
for (const provider of PROVIDERS) {
|
|
126
|
+
if (fileSize > provider.maxSize) {
|
|
127
|
+
attempts.push(`⏭️ ${provider.name}: fichier trop gros (max ${formatFileSize(provider.maxSize)})`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const url = await provider.upload(filePath, fileName, fileData, mimeType, expiry);
|
|
132
|
+
return { url, provider: provider.name, expiry: provider.expiry, attempts };
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
attempts.push(`❌ ${provider.name}: ${err.message}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`Tous les services d'upload ont échoué:\n${attempts.join('\n')}`);
|
|
139
|
+
}
|
|
140
|
+
// ─── Utils ──────────────────────────────────────────────────────────────────
|
|
141
|
+
function getMimeType(filename) {
|
|
142
|
+
const ext = path.extname(filename).toLowerCase();
|
|
143
|
+
const types = {
|
|
144
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
145
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
146
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg',
|
|
147
|
+
'.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac',
|
|
148
|
+
'.pdf': 'application/pdf', '.txt': 'text/plain',
|
|
149
|
+
'.json': 'application/json', '.html': 'text/html', '.css': 'text/css',
|
|
150
|
+
'.js': 'application/javascript', '.ts': 'text/typescript',
|
|
151
|
+
'.zip': 'application/zip', '.tar': 'application/x-tar',
|
|
152
|
+
'.gz': 'application/gzip', '.7z': 'application/x-7z-compressed',
|
|
153
|
+
};
|
|
154
|
+
return types[ext] || 'application/octet-stream';
|
|
155
|
+
}
|
|
156
|
+
function formatFileSize(bytes) {
|
|
157
|
+
if (bytes < 1024)
|
|
158
|
+
return `${bytes} B`;
|
|
159
|
+
if (bytes < 1024 * 1024)
|
|
160
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
161
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
162
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
163
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
164
|
+
}
|
|
165
|
+
// ─── Tool Definition ────────────────────────────────────────────────────────
|
|
166
|
+
export const fileToUrlTool = tool({
|
|
167
|
+
description: `Upload a local file to get a temporary public URL.
|
|
168
|
+
Uses a resilient multi-provider system with automatic fallback:
|
|
169
|
+
1. litterbox.catbox.moe (200MB, 1h-72h, anonymous)
|
|
170
|
+
2. tmpfile.link (100MB, 7 days, CDN global)
|
|
171
|
+
3. file.io (2GB, auto-destruct after 1 download)
|
|
172
|
+
If one service is down, the next one is tried automatically.
|
|
173
|
+
No API key needed, no account required.`,
|
|
174
|
+
args: {
|
|
175
|
+
file_path: tool.schema.string().describe('Absolute path to the local file to upload'),
|
|
176
|
+
expiry: tool.schema.enum(['1h', '12h', '24h', '72h']).optional()
|
|
177
|
+
.describe('How long the URL stays active on primary provider (default: 24h)'),
|
|
178
|
+
},
|
|
179
|
+
async execute(args, context) {
|
|
180
|
+
const expiry = args.expiry || '24h';
|
|
181
|
+
// Validate file exists
|
|
182
|
+
if (!fs.existsSync(args.file_path)) {
|
|
183
|
+
return `❌ Fichier introuvable: ${args.file_path}`;
|
|
184
|
+
}
|
|
185
|
+
const stats = fs.statSync(args.file_path);
|
|
186
|
+
if (stats.size === 0) {
|
|
187
|
+
return `❌ Fichier vide: ${args.file_path}`;
|
|
188
|
+
}
|
|
189
|
+
if (stats.size > 2 * 1024 * 1024 * 1024) {
|
|
190
|
+
return `❌ Fichier trop volumineux (${formatFileSize(stats.size)}). Max: 2 GB`;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
context.metadata({ title: `📤 Upload: ${path.basename(args.file_path)}` });
|
|
194
|
+
const result = await uploadWithFallback(args.file_path, stats.size, expiry);
|
|
195
|
+
const lines = [
|
|
196
|
+
`📤 Fichier Uploadé`,
|
|
197
|
+
`━━━━━━━━━━━━━━━━━━`,
|
|
198
|
+
`Fichier: ${path.basename(args.file_path)}`,
|
|
199
|
+
`Taille: ${formatFileSize(stats.size)}`,
|
|
200
|
+
`URL: ${result.url}`,
|
|
201
|
+
`Service: ${result.provider}`,
|
|
202
|
+
`Expiration: ${result.expiry}`,
|
|
203
|
+
``,
|
|
204
|
+
`Coût: Gratuit (hébergement anonyme)`,
|
|
205
|
+
];
|
|
206
|
+
// Show fallback attempts if any
|
|
207
|
+
if (result.attempts.length > 0) {
|
|
208
|
+
lines.push('', '⚠️ Fallbacks utilisés:');
|
|
209
|
+
lines.push(...result.attempts);
|
|
210
|
+
}
|
|
211
|
+
return lines.join('\n');
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
return `❌ Erreur Upload: ${err.message}`;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
});
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
2
|
+
import { emitStatusToast } from '../../server/toast.js';
|
|
3
|
+
import * as https from 'https';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import { resolveOutputDir, formatFileSize, TOOL_DIRS } from '../shared.js';
|
|
7
|
+
import { sanitizeFilename } from '../pollinations/shared.js';
|
|
8
|
+
import { getConfigDir } from '../../server/config.js';
|
|
9
|
+
// ─── Provider Defaults ───────────────────────────────────────────────────────
|
|
10
|
+
const CUT_API_URL = 'https://cut.esprit-artificiel.com';
|
|
11
|
+
const BACKGROUNDCUT_API_URL = 'https://backgroundcut.co/api/v1/cut/';
|
|
12
|
+
const HMAC_SECRET = "super_secret_community_key_2026"; // Sel caché dans le code transpilé
|
|
13
|
+
// ─── Key Storage ─────────────────────────────────────────────────────────────
|
|
14
|
+
const KEYS_FILE = path.join(getConfigDir(), 'backgroundcut_keys.json');
|
|
15
|
+
function loadKeys() {
|
|
16
|
+
try {
|
|
17
|
+
if (fs.existsSync(KEYS_FILE)) {
|
|
18
|
+
return JSON.parse(fs.readFileSync(KEYS_FILE, 'utf-8'));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
console.error(`[BackgroundCut] Error loading keys: ${e}`);
|
|
23
|
+
}
|
|
24
|
+
return { keys: [], currentIndex: 0 };
|
|
25
|
+
}
|
|
26
|
+
function getRotatedKeys() {
|
|
27
|
+
const store = loadKeys();
|
|
28
|
+
if (store.keys.length === 0)
|
|
29
|
+
return [];
|
|
30
|
+
// Return keys starting from currentIndex looping back to start
|
|
31
|
+
const before = store.keys.slice(store.currentIndex);
|
|
32
|
+
const after = store.keys.slice(0, store.currentIndex);
|
|
33
|
+
return [...before, ...after];
|
|
34
|
+
}
|
|
35
|
+
function advanceKeyIndex() {
|
|
36
|
+
const store = loadKeys();
|
|
37
|
+
if (store.keys.length === 0)
|
|
38
|
+
return;
|
|
39
|
+
store.currentIndex = (store.currentIndex + 1) % store.keys.length;
|
|
40
|
+
try {
|
|
41
|
+
const dir = path.dirname(KEYS_FILE);
|
|
42
|
+
if (!fs.existsSync(dir))
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
44
|
+
fs.writeFileSync(KEYS_FILE, JSON.stringify(store, null, 2));
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
console.error(`[BackgroundCut] Error saving key rotation: ${e}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// ─── HTTP Helpers ────────────────────────────────────────────────────────────
|
|
51
|
+
function httpRequest(url, options, body) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const req = https.request(url, options, (res) => {
|
|
54
|
+
const chunks = [];
|
|
55
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
56
|
+
res.on('end', () => {
|
|
57
|
+
const responseBody = Buffer.concat(chunks);
|
|
58
|
+
const statusCode = res.statusCode || 500;
|
|
59
|
+
let json;
|
|
60
|
+
try {
|
|
61
|
+
json = JSON.parse(responseBody.toString());
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
resolve({ statusCode, body: responseBody, json });
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
req.on('error', reject);
|
|
68
|
+
req.setTimeout(60000, () => { req.destroy(); reject(new Error('Request timeout (60s)')); });
|
|
69
|
+
if (body)
|
|
70
|
+
req.write(body);
|
|
71
|
+
req.end();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function downloadFile(url) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const protocol = url.startsWith('https') ? https : require('http');
|
|
77
|
+
protocol.get(url, (res) => {
|
|
78
|
+
// Handle redirects
|
|
79
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
80
|
+
return downloadFile(res.headers.location).then(resolve).catch(reject);
|
|
81
|
+
}
|
|
82
|
+
const chunks = [];
|
|
83
|
+
res.on('data', (c) => chunks.push(c));
|
|
84
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
85
|
+
}).on('error', reject);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// ─── Helper: Get Image Size (Linux/Unix 'file' command) ─────────────────────
|
|
89
|
+
function getImageSize(filePath) {
|
|
90
|
+
try {
|
|
91
|
+
const { spawnSync } = require('child_process');
|
|
92
|
+
const result = spawnSync('file', ['-b', filePath], { encoding: 'utf-8', shell: false });
|
|
93
|
+
if (result.stdout) {
|
|
94
|
+
// Regex for "IDAT, 800 x 600," or ", 800 x 600,"
|
|
95
|
+
const match = result.stdout.match(/, (\d+) ?x ?(\d+),/);
|
|
96
|
+
if (match) {
|
|
97
|
+
return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
// Fallback or silence
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
// ─── Provider: cut.esprit-artificiel.com (returns binary PNG directly) ────────
|
|
107
|
+
async function removeViaCut(imageData, filename, mimeType) {
|
|
108
|
+
const boundary = `----FormBoundary${Date.now()}`;
|
|
109
|
+
const parts = [];
|
|
110
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`));
|
|
111
|
+
parts.push(imageData);
|
|
112
|
+
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
|
113
|
+
const body = Buffer.concat(parts);
|
|
114
|
+
const url = new URL(`${CUT_API_URL}/remove-bg`);
|
|
115
|
+
const headers = {
|
|
116
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
117
|
+
'Content-Length': body.length,
|
|
118
|
+
'User-Agent': 'OpenCode-Pollinations-Plugin/6.1',
|
|
119
|
+
};
|
|
120
|
+
// 1. Vérifier si l'utilisateur (Franck) a configuré une clé VIP localement
|
|
121
|
+
let vipKey = null;
|
|
122
|
+
try {
|
|
123
|
+
const vipPath = path.join(getConfigDir(), 'cut_vip.json');
|
|
124
|
+
if (fs.existsSync(vipPath)) {
|
|
125
|
+
const data = JSON.parse(fs.readFileSync(vipPath, 'utf-8'));
|
|
126
|
+
if (data.vip_key)
|
|
127
|
+
vipKey = data.vip_key;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
console.error(`[Cut VIP] Error loading vip key: ${e}`);
|
|
132
|
+
}
|
|
133
|
+
if (vipKey) {
|
|
134
|
+
// Mode Fast-Lane (VIP) : la requête passe directement en tête de file
|
|
135
|
+
headers['X-Api-Key'] = vipKey;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
// Mode Communauté (Plugin public) : Génération de la signature dynamique courte durée (Anti-leech)
|
|
139
|
+
const timestamp = Date.now().toString();
|
|
140
|
+
const payloadToSign = `request-rembg-v1:${timestamp}`;
|
|
141
|
+
const signature = require('crypto')
|
|
142
|
+
.createHmac('sha256', HMAC_SECRET)
|
|
143
|
+
.update(payloadToSign)
|
|
144
|
+
.digest('hex');
|
|
145
|
+
headers['X-Cut-Timestamp'] = timestamp;
|
|
146
|
+
headers['Authorization'] = `Bearer community:${signature}`;
|
|
147
|
+
}
|
|
148
|
+
const res = await httpRequest(url.toString(), {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers
|
|
151
|
+
}, body);
|
|
152
|
+
if (res.statusCode >= 400) {
|
|
153
|
+
throw new Error(`CUT API Error ${res.statusCode}: ${res.body.toString().substring(0, 200)}`);
|
|
154
|
+
}
|
|
155
|
+
return res.body;
|
|
156
|
+
}
|
|
157
|
+
// ─── Provider: BackgroundCut.co (returns JSON with output_image_url) ─────────
|
|
158
|
+
async function removeViaBackgroundCut(imageData, filename, mimeType, apiKey, quality = 'medium', returnFormat = 'png', maxResolution) {
|
|
159
|
+
const boundary = `----FormBoundary${Date.now()}`;
|
|
160
|
+
const parts = [];
|
|
161
|
+
// File field
|
|
162
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`));
|
|
163
|
+
parts.push(imageData);
|
|
164
|
+
// Quality
|
|
165
|
+
parts.push(Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="quality"\r\n\r\n${quality}`));
|
|
166
|
+
// Return format
|
|
167
|
+
parts.push(Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="return_format"\r\n\r\n${returnFormat.toUpperCase()}`));
|
|
168
|
+
// Max resolution
|
|
169
|
+
if (maxResolution) {
|
|
170
|
+
parts.push(Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="max_resolution"\r\n\r\n${maxResolution}`));
|
|
171
|
+
}
|
|
172
|
+
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
|
173
|
+
const body = Buffer.concat(parts);
|
|
174
|
+
const res = await httpRequest(BACKGROUNDCUT_API_URL, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: {
|
|
177
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
178
|
+
'Content-Length': body.length,
|
|
179
|
+
'Authorization': `Token ${apiKey}`,
|
|
180
|
+
'User-Agent': 'OpenCode-Pollinations-Plugin/6.0',
|
|
181
|
+
},
|
|
182
|
+
}, body);
|
|
183
|
+
// Error handling with specific codes
|
|
184
|
+
if (res.statusCode === 401)
|
|
185
|
+
throw new Error('BGCUT_401:Invalid or missing API key');
|
|
186
|
+
if (res.statusCode === 402)
|
|
187
|
+
throw new Error('BGCUT_402:No credits remaining');
|
|
188
|
+
if (res.statusCode === 429)
|
|
189
|
+
throw new Error('BGCUT_429:Rate limit exceeded');
|
|
190
|
+
if (res.statusCode === 413)
|
|
191
|
+
throw new Error('BGCUT_413:File too large (max 12MB)');
|
|
192
|
+
if (res.statusCode >= 400) {
|
|
193
|
+
const msg = res.json?.error || res.body.toString().substring(0, 200);
|
|
194
|
+
throw new Error(`BGCUT_${res.statusCode}:${msg}`);
|
|
195
|
+
}
|
|
196
|
+
// Download the result image from the URL
|
|
197
|
+
const outputUrl = res.json?.output_image_url;
|
|
198
|
+
if (!outputUrl)
|
|
199
|
+
throw new Error('BackgroundCut returned no output URL');
|
|
200
|
+
return await downloadFile(outputUrl);
|
|
201
|
+
}
|
|
202
|
+
// ─── Main Tool ───────────────────────────────────────────────────────────────
|
|
203
|
+
export const removeBackgroundTool = tool({
|
|
204
|
+
description: `Remove the background from an image, producing a transparent PNG or WebP.
|
|
205
|
+
|
|
206
|
+
**Providers:**
|
|
207
|
+
- \`cut\` (default free) — Built-in u2netp AI. Slower. Ignores quality/format/resolution.
|
|
208
|
+
- \`backgroundcut\` — Premium API. Requires API key. Supports all parameters.
|
|
209
|
+
|
|
210
|
+
**Setup:** Use \`rmbg_keys\` tool to manage API keys.
|
|
211
|
+
**Auto mode:** Uses BackgroundCut if key is available, falls back to cut.`,
|
|
212
|
+
args: {
|
|
213
|
+
image_path: tool.schema.string().describe('Absolute path to the image file'),
|
|
214
|
+
filename: tool.schema.string().optional().describe('Custom output filename (e.g. "my_image.png") or name without extension'),
|
|
215
|
+
output_path: tool.schema.string().optional().describe('Custom output directory. Default: ~/Downloads/pollinations/rembg/'),
|
|
216
|
+
provider: tool.schema.string().optional().describe('Provider: "auto" (default), "cut" (free), or "backgroundcut" (premium)'), // Removed .enum() as it caused errors
|
|
217
|
+
api_key: tool.schema.string().optional().describe('BackgroundCut API key (overrides stored keys)'),
|
|
218
|
+
quality: tool.schema.string().optional().describe('BackgroundCut only: "low", "medium" (default), "high"'),
|
|
219
|
+
return_format: tool.schema.string().optional().describe('BackgroundCut only: "png" (default), "webp"'),
|
|
220
|
+
max_resolution: tool.schema.number().optional().describe('BackgroundCut only: Max output resolution in pixels'),
|
|
221
|
+
},
|
|
222
|
+
async execute(args, context) {
|
|
223
|
+
// ── Validate input ──
|
|
224
|
+
if (!fs.existsSync(args.image_path)) {
|
|
225
|
+
return `❌ File not found: ${args.image_path}`;
|
|
226
|
+
}
|
|
227
|
+
const ext = path.extname(args.image_path).toLowerCase();
|
|
228
|
+
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
|
|
229
|
+
return `❌ Unsupported format: ${ext}. Use PNG, JPEG, or WebP.`;
|
|
230
|
+
}
|
|
231
|
+
const inputStats = fs.statSync(args.image_path);
|
|
232
|
+
if (inputStats.size > 12 * 1024 * 1024) {
|
|
233
|
+
return `❌ File too large (${formatFileSize(inputStats.size)}). Max: 12MB`;
|
|
234
|
+
}
|
|
235
|
+
// ── Resolve provider ──
|
|
236
|
+
const provider = (args.provider || 'auto');
|
|
237
|
+
const quality = (args.quality || 'medium');
|
|
238
|
+
const returnFormat = (args.return_format || 'png');
|
|
239
|
+
const imageData = fs.readFileSync(args.image_path);
|
|
240
|
+
const mimeType = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
|
|
241
|
+
const basename = path.basename(args.image_path);
|
|
242
|
+
// ─── Resolve API keys ──
|
|
243
|
+
let keysToCheck = [];
|
|
244
|
+
if (args.api_key) {
|
|
245
|
+
keysToCheck = [args.api_key];
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
keysToCheck = getRotatedKeys();
|
|
249
|
+
}
|
|
250
|
+
// ── Determine initial provider strategy ──
|
|
251
|
+
// If specific provider requested, we stick to it (unless auto fallback)
|
|
252
|
+
// If auto, we try keys then fallback to cut
|
|
253
|
+
let effectiveProvider = provider;
|
|
254
|
+
if (provider === 'auto') {
|
|
255
|
+
// If we have keys, start with backgroundcut, else cut
|
|
256
|
+
effectiveProvider = keysToCheck.length > 0 ? 'backgroundcut' : 'cut';
|
|
257
|
+
}
|
|
258
|
+
// ── Info message when no BackgroundCut key ──
|
|
259
|
+
if (keysToCheck.length === 0 && (provider === 'auto' || provider === 'backgroundcut')) {
|
|
260
|
+
// console.log("No BackgroundCut key found. Using free provider."); // SILENCED
|
|
261
|
+
context.metadata({
|
|
262
|
+
title: "RMBG (Free)",
|
|
263
|
+
metadata: { type: 'info', message: "Mode Gratuit (Pas de clé détectée)" }
|
|
264
|
+
});
|
|
265
|
+
const noKeyMsg = [
|
|
266
|
+
`ℹ️ **No BackgroundCut API key configured** — using free provider (slower, rate-limited).`,
|
|
267
|
+
``,
|
|
268
|
+
`🚀 **Want faster, higher-quality results?**`,
|
|
269
|
+
`1. Sign up at https://backgroundcut.co (5$ free credits, 60 days)`,
|
|
270
|
+
`2. Run: \`rmbg_keys action=add key=YOUR_API_KEY\``,
|
|
271
|
+
`3. Multiple keys supported — they rotate automatically!`,
|
|
272
|
+
].join('\n');
|
|
273
|
+
if (provider === 'backgroundcut' && !args.api_key) {
|
|
274
|
+
return `❌ BackgroundCut provider selected but no API keys stored.\n\n${noKeyMsg}`;
|
|
275
|
+
}
|
|
276
|
+
// In auto mode, we continue with free provider
|
|
277
|
+
context.metadata({ title: `ℹ️ Using free provider (no BackgroundCut key)` });
|
|
278
|
+
}
|
|
279
|
+
// ── Resolve output filename ──
|
|
280
|
+
const outputDir = resolveOutputDir(TOOL_DIRS.rembg, args.output_path);
|
|
281
|
+
let finalFilename = '';
|
|
282
|
+
const targetExt = (returnFormat === 'webp' && effectiveProvider === 'backgroundcut') ? '.webp' : '.png';
|
|
283
|
+
if (args.filename) {
|
|
284
|
+
if (args.filename.toLowerCase().endsWith(targetExt)) {
|
|
285
|
+
finalFilename = args.filename;
|
|
286
|
+
}
|
|
287
|
+
else if (args.filename.match(/\.[a-z0-9]+$/i)) {
|
|
288
|
+
// Force proper extension
|
|
289
|
+
finalFilename = args.filename.replace(/\.[a-z0-9]+$/i, targetExt);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
finalFilename = `${args.filename}${targetExt}`;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
finalFilename = `${path.basename(args.image_path, ext)}_nobg${targetExt}`;
|
|
297
|
+
}
|
|
298
|
+
if (args.filename) {
|
|
299
|
+
finalFilename = sanitizeFilename(finalFilename);
|
|
300
|
+
}
|
|
301
|
+
const outputPath = path.join(outputDir, finalFilename);
|
|
302
|
+
const outputExt = path.extname(outputPath).replace('.', ''); // Fix for log display
|
|
303
|
+
// ── Execute ──
|
|
304
|
+
try {
|
|
305
|
+
let resultBuffer = null;
|
|
306
|
+
let usedProvider = 'cut'; // Default
|
|
307
|
+
let fallbackUsed = false;
|
|
308
|
+
let successKey = '';
|
|
309
|
+
// 1. Try BackgroundCut loop if applicable
|
|
310
|
+
if (effectiveProvider === 'backgroundcut' && keysToCheck.length > 0) {
|
|
311
|
+
emitStatusToast('info', `Démarrage: ${basename}`, '✂️ BackgroundCut');
|
|
312
|
+
for (const key of keysToCheck) {
|
|
313
|
+
try {
|
|
314
|
+
emitStatusToast('info', `Clé ${key.substring(0, 8)}...`, '>>> Rotation RMBG');
|
|
315
|
+
// console.log(`[RMBG] Trying key ${key.substring(0, 8)}...`); // SILENCED
|
|
316
|
+
resultBuffer = await removeViaBackgroundCut(imageData, basename, mimeType, key, quality, returnFormat, args.max_resolution);
|
|
317
|
+
// Success!
|
|
318
|
+
usedProvider = 'backgroundcut';
|
|
319
|
+
successKey = key;
|
|
320
|
+
// Advance index globally so next call uses next key (fair rotation)
|
|
321
|
+
advanceKeyIndex();
|
|
322
|
+
// Only set final metadata on success
|
|
323
|
+
context.metadata({
|
|
324
|
+
title: "RMBG (Premium)",
|
|
325
|
+
metadata: { type: 'success', message: "Détourage HD réussi (BackgroundCut)" }
|
|
326
|
+
});
|
|
327
|
+
break; // Exit loop on success
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
const isFallbackable = err.message.startsWith('BGCUT_402') ||
|
|
331
|
+
err.message.startsWith('BGCUT_429') ||
|
|
332
|
+
err.message.startsWith('BGCUT_401');
|
|
333
|
+
if (isFallbackable) {
|
|
334
|
+
// console.log(`⚠️ Key ${key.substring(0, 8)} failed (${err.message}). Rotating...`); // SILENCED
|
|
335
|
+
continue; // Try next key
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
throw err; // Fatal error (file size etc)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// 2. Fallback to Free Provider if no result yet
|
|
344
|
+
if (!resultBuffer) {
|
|
345
|
+
if (effectiveProvider === 'backgroundcut' && provider !== 'auto') {
|
|
346
|
+
throw new Error('All provided keys failed or are expired.');
|
|
347
|
+
}
|
|
348
|
+
if (effectiveProvider === 'backgroundcut') {
|
|
349
|
+
emitStatusToast('warning', 'Toutes les clés ont échoué. Mode Gratuit activé.', '⚠️ Fallback');
|
|
350
|
+
// console.log('⚠️ All BackgroundCut keys failed. Falling back to free provider.'); // SILENCED
|
|
351
|
+
fallbackUsed = true;
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
emitStatusToast('info', `Détourage via API Gratuite: ${basename}`, '✂️ Free RMBG');
|
|
355
|
+
}
|
|
356
|
+
resultBuffer = await removeViaCut(imageData, basename, mimeType);
|
|
357
|
+
context.metadata({
|
|
358
|
+
title: "RMBG (Free)",
|
|
359
|
+
metadata: { type: 'success', message: "Détourage Standard réussi" }
|
|
360
|
+
});
|
|
361
|
+
usedProvider = 'cut (free)';
|
|
362
|
+
}
|
|
363
|
+
if (!resultBuffer || resultBuffer.length < 100) {
|
|
364
|
+
return `❌ Background removal returned invalid data.`;
|
|
365
|
+
}
|
|
366
|
+
const finalPath = path.resolve(outputPath);
|
|
367
|
+
fs.writeFileSync(finalPath, resultBuffer);
|
|
368
|
+
// Double check existence
|
|
369
|
+
if (!fs.existsSync(finalPath)) {
|
|
370
|
+
throw new Error(`File write failed at: ${finalPath}`);
|
|
371
|
+
}
|
|
372
|
+
const dims = getImageSize(finalPath);
|
|
373
|
+
const dimStr = dims ? `${dims.width}×${dims.height}` : 'N/A';
|
|
374
|
+
const lines = [
|
|
375
|
+
`✂️ Background Removed`,
|
|
376
|
+
`━━━━━━━━━━━━━━━━━━━━━`,
|
|
377
|
+
`Input: ${basename} (${formatFileSize(inputStats.size)})`,
|
|
378
|
+
`Output: ${finalPath}`,
|
|
379
|
+
`Size: ${formatFileSize(resultBuffer.length)}`,
|
|
380
|
+
`Dimensions: ${dimStr}`,
|
|
381
|
+
`Format: ${outputExt.toUpperCase()} (transparent)`,
|
|
382
|
+
`Provider: ${usedProvider}`,
|
|
383
|
+
`Cost: Free`,
|
|
384
|
+
];
|
|
385
|
+
if (fallbackUsed) {
|
|
386
|
+
lines.push(``, `⚠️ BackgroundCut key may be expired/rate-limited. Check with \`rmbg_keys action=list\``);
|
|
387
|
+
}
|
|
388
|
+
// Add info message for users without key
|
|
389
|
+
if (keysToCheck.length === 0 && usedProvider.includes('free')) {
|
|
390
|
+
lines.push(``, `💡 Tip: Add a BackgroundCut key for faster HD results: \`rmbg_keys action=add key=...\``);
|
|
391
|
+
}
|
|
392
|
+
return lines.join('\n');
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
if (err.message.includes('429') || err.message.includes('rate')) {
|
|
396
|
+
return `⏳ Rate limited. Please try again in 30 seconds.`;
|
|
397
|
+
}
|
|
398
|
+
if (err.message.startsWith('BGCUT_402')) {
|
|
399
|
+
return `💳 BackgroundCut: No credits remaining. Add a new key or wait for renewal.\nRun: \`rmbg_keys action=add key=...\``;
|
|
400
|
+
}
|
|
401
|
+
return `❌ Background Removal Error: ${err.message}`;
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
});
|