opencode-pollinations-plugin 6.1.0-beta.8 → 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 -293
- 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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { estimateImageCost, estimateVideoCost, estimateTtsCost, per1pollen } from './shared.js';
|
|
2
|
+
import { ModelRegistry } from '../../server/models/index.js';
|
|
3
|
+
async function testEstimators() {
|
|
4
|
+
console.log("Loading models...");
|
|
5
|
+
await ModelRegistry.ensureFresh();
|
|
6
|
+
const imageTests = ['flux', 'flux-pro', 'turbo'];
|
|
7
|
+
console.log("\n=== IMAGE ESTIMATIONS ===");
|
|
8
|
+
for (const model of imageTests) {
|
|
9
|
+
const cost = estimateImageCost(model);
|
|
10
|
+
console.log(`[${model}]: Cost = ${cost}, 1 pollen ≈ ${per1pollen(cost)} images`);
|
|
11
|
+
}
|
|
12
|
+
const videoTests = ['ltx-2', 'wan', 'veo'];
|
|
13
|
+
console.log("\n=== VIDEO ESTIMATIONS (6s) ===");
|
|
14
|
+
for (const model of videoTests) {
|
|
15
|
+
const cost = estimateVideoCost(model, 6);
|
|
16
|
+
console.log(`[${model}]: Cost = ${cost}, 1 pollen ≈ ${per1pollen(cost)} vidéos`);
|
|
17
|
+
}
|
|
18
|
+
console.log("\n=== TTS ESTIMATIONS (200 chars) ===");
|
|
19
|
+
const ttsCost = estimateTtsCost(200);
|
|
20
|
+
console.log(`[elevenlabs]: Cost = ${ttsCost}, 1 pollen ≈ ${per1pollen(ttsCost)} generations`);
|
|
21
|
+
}
|
|
22
|
+
testEstimators();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcribe_audio Tool - Pollinations Speech-to-Text (STT)
|
|
3
|
+
*
|
|
4
|
+
* Updated: 2026-02-12 - Verified API Reference
|
|
5
|
+
*
|
|
6
|
+
* 1. whisper-large-v3 (DEFAULT): High accuracy Whisper model
|
|
7
|
+
* 2. whisper-1: Standard Whisper model
|
|
8
|
+
* 3. scribe: ElevenLabs Scribe v2
|
|
9
|
+
*
|
|
10
|
+
* All models use /v1/audio/transcriptions (POST multipart)
|
|
11
|
+
*/
|
|
12
|
+
import { type ToolDefinition } from '@opencode-ai/plugin/tool';
|
|
13
|
+
export declare const polliSttTool: ToolDefinition;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcribe_audio Tool - Pollinations Speech-to-Text (STT)
|
|
3
|
+
*
|
|
4
|
+
* Updated: 2026-02-12 - Verified API Reference
|
|
5
|
+
*
|
|
6
|
+
* 1. whisper-large-v3 (DEFAULT): High accuracy Whisper model
|
|
7
|
+
* 2. whisper-1: Standard Whisper model
|
|
8
|
+
* 3. scribe: ElevenLabs Scribe v2
|
|
9
|
+
*
|
|
10
|
+
* All models use /v1/audio/transcriptions (POST multipart)
|
|
11
|
+
*/
|
|
12
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import { getApiKey, httpsPostMultipart, ensureDir, formatFileSize, getAudioModels, } from './shared.js';
|
|
16
|
+
import { t } from '../../locales/index.js';
|
|
17
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
18
|
+
const DEFAULT_MODEL = 'whisper-large-v3';
|
|
19
|
+
const SUPPORTED_FORMATS = ['mp3', 'wav', 'm4a', 'webm', 'mp4', 'mpeg', 'mpga', 'ogg'];
|
|
20
|
+
// ─── Tool Definition ──────────────────────────────────────────────────────
|
|
21
|
+
export const polliSttTool = tool({
|
|
22
|
+
description: t('tools.polli_transcribe_audio.desc'),
|
|
23
|
+
args: {
|
|
24
|
+
file: tool.schema.string().describe(t('tools.polli_transcribe_audio.arg_file')),
|
|
25
|
+
model: tool.schema.string().describe(t('tools.polli_transcribe_audio.arg_model', { model: DEFAULT_MODEL })),
|
|
26
|
+
language: tool.schema.string().optional().describe(t('tools.polli_transcribe_audio.arg_language')),
|
|
27
|
+
save_transcript: tool.schema.boolean().optional().describe(t('tools.polli_transcribe_audio.arg_save')),
|
|
28
|
+
},
|
|
29
|
+
async execute(args, context) {
|
|
30
|
+
const apiKey = getApiKey();
|
|
31
|
+
if (!apiKey) {
|
|
32
|
+
return t('tools.polli_transcribe_audio.req_key');
|
|
33
|
+
}
|
|
34
|
+
const model = args.model;
|
|
35
|
+
// Validate model
|
|
36
|
+
const audioModels = getAudioModels();
|
|
37
|
+
const modelInfo = audioModels[model];
|
|
38
|
+
if (!modelInfo || (modelInfo.type !== 'stt' && modelInfo.type !== 'both')) {
|
|
39
|
+
const models = Object.entries(audioModels)
|
|
40
|
+
.filter(([, info]) => info.type === 'stt' || info.type === 'both')
|
|
41
|
+
.map(([name]) => name)
|
|
42
|
+
.join(', ');
|
|
43
|
+
return t('tools.polli_transcribe_audio.err_unknown_model', { model, models });
|
|
44
|
+
}
|
|
45
|
+
// Check file
|
|
46
|
+
let audioPath = args.file;
|
|
47
|
+
let audioBuffer;
|
|
48
|
+
let fileName = 'audio.mp3';
|
|
49
|
+
if (audioPath.startsWith('http://') || audioPath.startsWith('https://')) {
|
|
50
|
+
// Download from URL
|
|
51
|
+
context.metadata({ title: t('tools.polli_transcribe_audio.toast_dl') });
|
|
52
|
+
try {
|
|
53
|
+
const https = await import('https');
|
|
54
|
+
const http = await import('http');
|
|
55
|
+
const protocol = audioPath.startsWith('https') ? https : http;
|
|
56
|
+
audioBuffer = await new Promise((resolve, reject) => {
|
|
57
|
+
const chunks = [];
|
|
58
|
+
protocol.get(audioPath, (res) => {
|
|
59
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
60
|
+
// Follow redirect
|
|
61
|
+
const redirectUrl = res.headers.location;
|
|
62
|
+
if (redirectUrl) {
|
|
63
|
+
const redirectProtocol = redirectUrl.startsWith('https') ? https : http;
|
|
64
|
+
redirectProtocol.get(redirectUrl, (res2) => {
|
|
65
|
+
res2.on('data', chunk => chunks.push(chunk));
|
|
66
|
+
res2.on('end', () => resolve(Buffer.concat(chunks)));
|
|
67
|
+
res2.on('error', reject);
|
|
68
|
+
}).on('error', reject);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
res.on('data', chunk => chunks.push(chunk));
|
|
73
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
74
|
+
res.on('error', reject);
|
|
75
|
+
}).on('error', reject);
|
|
76
|
+
});
|
|
77
|
+
// Extract filename from URL
|
|
78
|
+
try {
|
|
79
|
+
const urlPath = new URL(audioPath).pathname;
|
|
80
|
+
fileName = path.basename(urlPath) || 'audio.mp3';
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
fileName = 'audio.mp3';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return t('tools.polli_transcribe_audio.err_dl', { error: err.message });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// Local file
|
|
92
|
+
if (!fs.existsSync(audioPath)) {
|
|
93
|
+
return t('tools.polli_transcribe_audio.err_not_found', { path: audioPath });
|
|
94
|
+
}
|
|
95
|
+
// Check format
|
|
96
|
+
const ext = path.extname(audioPath).toLowerCase().replace('.', '');
|
|
97
|
+
if (!SUPPORTED_FORMATS.includes(ext)) {
|
|
98
|
+
return t('tools.polli_transcribe_audio.err_format', { ext, formats: SUPPORTED_FORMATS.join(', ') });
|
|
99
|
+
}
|
|
100
|
+
audioBuffer = fs.readFileSync(audioPath);
|
|
101
|
+
fileName = path.basename(audioPath);
|
|
102
|
+
}
|
|
103
|
+
const fileSize = audioBuffer.length;
|
|
104
|
+
// Metadata
|
|
105
|
+
context.metadata({ title: t('tools.polli_transcribe_audio.toast_start', { model, size: formatFileSize(fileSize) }) });
|
|
106
|
+
try {
|
|
107
|
+
let transcript = '';
|
|
108
|
+
let detectedLanguage = '';
|
|
109
|
+
// === All STT models use multipart endpoint ===
|
|
110
|
+
const fields = {
|
|
111
|
+
file: audioBuffer,
|
|
112
|
+
model: model,
|
|
113
|
+
};
|
|
114
|
+
if (args.language) {
|
|
115
|
+
fields.language = args.language;
|
|
116
|
+
}
|
|
117
|
+
const response = await httpsPostMultipart('https://gen.pollinations.ai/v1/audio/transcriptions', fields, {
|
|
118
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
119
|
+
});
|
|
120
|
+
const data = JSON.parse(response.data.toString());
|
|
121
|
+
transcript = data.text || '';
|
|
122
|
+
detectedLanguage = data.language || '';
|
|
123
|
+
if (!transcript) {
|
|
124
|
+
return t('tools.polli_transcribe_audio.err_no_transcript');
|
|
125
|
+
}
|
|
126
|
+
// Build result
|
|
127
|
+
const lines = [
|
|
128
|
+
t('tools.polli_transcribe_audio.res_title'),
|
|
129
|
+
`━━━━━━━━━━━━━━━━━━`,
|
|
130
|
+
t('tools.polli_transcribe_audio.res_file', { file: fileName }),
|
|
131
|
+
t('tools.polli_transcribe_audio.res_size', { size: formatFileSize(fileSize) }),
|
|
132
|
+
t('tools.polli_transcribe_audio.res_model', { model }),
|
|
133
|
+
];
|
|
134
|
+
if (detectedLanguage) {
|
|
135
|
+
lines.push(t('tools.polli_transcribe_audio.res_lang_det', { lang: detectedLanguage }));
|
|
136
|
+
}
|
|
137
|
+
if (args.language) {
|
|
138
|
+
lines.push(t('tools.polli_transcribe_audio.res_lang_req', { lang: args.language }));
|
|
139
|
+
}
|
|
140
|
+
lines.push(``);
|
|
141
|
+
lines.push(t('tools.polli_transcribe_audio.res_transcript_title'));
|
|
142
|
+
lines.push(``);
|
|
143
|
+
lines.push(transcript);
|
|
144
|
+
// Save transcript if requested
|
|
145
|
+
if (args.save_transcript) {
|
|
146
|
+
const outputDir = process.env.HOME
|
|
147
|
+
? path.join(process.env.HOME, 'Downloads', 'pollinations', 'transcripts')
|
|
148
|
+
: '/tmp';
|
|
149
|
+
ensureDir(outputDir);
|
|
150
|
+
const baseName = path.basename(fileName, path.extname(fileName));
|
|
151
|
+
const outputPath = path.join(outputDir, `${baseName}_transcript.txt`);
|
|
152
|
+
fs.writeFileSync(outputPath, transcript);
|
|
153
|
+
lines.push(``);
|
|
154
|
+
lines.push(t('tools.polli_transcribe_audio.res_saved', { path: outputPath }));
|
|
155
|
+
}
|
|
156
|
+
return lines.join('\n');
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
if (err.message?.includes('402') || err.message?.includes('Payment')) {
|
|
160
|
+
return t('tools.polli_transcribe_audio.err_pollen');
|
|
161
|
+
}
|
|
162
|
+
if (err.message?.includes('401') || err.message?.includes('403')) {
|
|
163
|
+
return t('tools.polli_transcribe_audio.err_auth');
|
|
164
|
+
}
|
|
165
|
+
if (err.message?.includes('413') || err.message?.includes('too large')) {
|
|
166
|
+
return t('tools.polli_transcribe_audio.err_large');
|
|
167
|
+
}
|
|
168
|
+
return t('tools.polli_transcribe_audio.err_stt', { error: err.message });
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import * as https from 'https';
|
|
6
|
+
import * as http from 'http';
|
|
7
|
+
import { resolveOutputDir, formatFileSize, safeName, formatTimestamp, TOOL_DIRS } from '../shared.js';
|
|
8
|
+
import { hasSystemFFmpeg, getFFmpegInstallInstructions, runFFmpeg, runFFprobe } from '../ffmpeg.js';
|
|
9
|
+
// ─── Download helper ────────────────────────────────────────────────────────
|
|
10
|
+
function downloadFile(url) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const ext = path.extname(new URL(url).pathname) || '.mp4';
|
|
13
|
+
const tempPath = path.join(os.tmpdir(), `video_${Date.now()}${ext}`);
|
|
14
|
+
const proto = url.startsWith('https') ? https : http;
|
|
15
|
+
const req = proto.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; OpenCode-Plugin/6.0)' } }, (res) => {
|
|
16
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
17
|
+
return downloadFile(res.headers.location).then(resolve).catch(reject);
|
|
18
|
+
}
|
|
19
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
20
|
+
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
21
|
+
}
|
|
22
|
+
const ws = fs.createWriteStream(tempPath);
|
|
23
|
+
res.pipe(ws);
|
|
24
|
+
ws.on('finish', () => { ws.close(); resolve(tempPath); });
|
|
25
|
+
ws.on('error', reject);
|
|
26
|
+
});
|
|
27
|
+
req.on('error', reject);
|
|
28
|
+
req.setTimeout(120000, () => { req.destroy(); reject(new Error('Timeout (120s)')); });
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// ─── Tool Definition ────────────────────────────────────────────────────────
|
|
32
|
+
export const extractAudioTool = tool({
|
|
33
|
+
description: `Extract the audio track from a video file or URL.
|
|
34
|
+
Outputs MP3, WAV, AAC, or FLAC format.
|
|
35
|
+
Can optionally extract only a time range (start/end).
|
|
36
|
+
Requires system ffmpeg installed.
|
|
37
|
+
Free to use — no API key needed.`,
|
|
38
|
+
args: {
|
|
39
|
+
source: tool.schema.string().describe('Video file path (absolute) or URL'),
|
|
40
|
+
format: tool.schema.enum(['mp3', 'wav', 'aac', 'flac']).optional()
|
|
41
|
+
.describe('Output audio format (default: mp3)'),
|
|
42
|
+
start: tool.schema.string().optional()
|
|
43
|
+
.describe('Start time to extract from (e.g. "00:00:10" or "10")'),
|
|
44
|
+
end: tool.schema.string().optional()
|
|
45
|
+
.describe('End time to extract to (e.g. "00:01:30" or "90")'),
|
|
46
|
+
filename: tool.schema.string().optional()
|
|
47
|
+
.describe('Custom output filename (without extension). Auto-generated if omitted'),
|
|
48
|
+
output_path: tool.schema.string().optional()
|
|
49
|
+
.describe('Custom output directory. Default: ~/Downloads/pollinations/audio/'),
|
|
50
|
+
},
|
|
51
|
+
async execute(args, context) {
|
|
52
|
+
if (!hasSystemFFmpeg()) {
|
|
53
|
+
return [
|
|
54
|
+
`❌ FFmpeg non trouvé!`,
|
|
55
|
+
``,
|
|
56
|
+
`Cet outil nécessite ffmpeg :`,
|
|
57
|
+
getFFmpegInstallInstructions().split('\n').map(l => ` • ${l}`).join('\n')
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|
|
60
|
+
// Resolve source
|
|
61
|
+
let videoPath;
|
|
62
|
+
let isRemote = false;
|
|
63
|
+
if (args.source.startsWith('http://') || args.source.startsWith('https://')) {
|
|
64
|
+
isRemote = true;
|
|
65
|
+
context.metadata({ title: `🎵 Téléchargement vidéo...` });
|
|
66
|
+
try {
|
|
67
|
+
videoPath = await downloadFile(args.source);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return `❌ Erreur téléchargement: ${err.message}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
videoPath = args.source;
|
|
75
|
+
if (!fs.existsSync(videoPath)) {
|
|
76
|
+
return `❌ Fichier introuvable: ${videoPath}`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Check if video has audio
|
|
80
|
+
try {
|
|
81
|
+
// Using runFFprobe helper
|
|
82
|
+
const probe = runFFprobe([
|
|
83
|
+
'-v', 'quiet',
|
|
84
|
+
'-select_streams', 'a',
|
|
85
|
+
'-show_entries', 'stream=codec_type',
|
|
86
|
+
'-of', 'csv=p=0',
|
|
87
|
+
videoPath
|
|
88
|
+
], { timeout: 10000 }).trim();
|
|
89
|
+
if (!probe) {
|
|
90
|
+
if (isRemote)
|
|
91
|
+
try {
|
|
92
|
+
fs.unlinkSync(videoPath);
|
|
93
|
+
}
|
|
94
|
+
catch { }
|
|
95
|
+
return `❌ Aucune piste audio détectée dans cette vidéo.`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch { }
|
|
99
|
+
const outputFormat = args.format || 'mp3';
|
|
100
|
+
const outputDir = resolveOutputDir(TOOL_DIRS.audio, args.output_path);
|
|
101
|
+
const baseName = args.filename
|
|
102
|
+
? safeName(args.filename)
|
|
103
|
+
: safeName(path.basename(videoPath, path.extname(videoPath)));
|
|
104
|
+
const outputFile = path.join(outputDir, `${baseName}.${outputFormat}`);
|
|
105
|
+
try {
|
|
106
|
+
context.metadata({ title: `🎵 Extraction audio...` });
|
|
107
|
+
// Build ffmpeg args
|
|
108
|
+
const ffmpegArgs = ['-y', '-i', videoPath, '-vn'];
|
|
109
|
+
// Time range
|
|
110
|
+
if (args.start)
|
|
111
|
+
ffmpegArgs.push('-ss', args.start);
|
|
112
|
+
if (args.end)
|
|
113
|
+
ffmpegArgs.push('-to', args.end);
|
|
114
|
+
// Format-specific encoding
|
|
115
|
+
switch (outputFormat) {
|
|
116
|
+
case 'mp3':
|
|
117
|
+
ffmpegArgs.push('-acodec', 'libmp3lame', '-q:a', '2');
|
|
118
|
+
break;
|
|
119
|
+
case 'wav':
|
|
120
|
+
ffmpegArgs.push('-acodec', 'pcm_s16le');
|
|
121
|
+
break;
|
|
122
|
+
case 'aac':
|
|
123
|
+
ffmpegArgs.push('-acodec', 'aac', '-b:a', '192k');
|
|
124
|
+
break;
|
|
125
|
+
case 'flac':
|
|
126
|
+
ffmpegArgs.push('-acodec', 'flac');
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
ffmpegArgs.push(outputFile);
|
|
130
|
+
runFFmpeg(ffmpegArgs, { timeout: 120000 });
|
|
131
|
+
// Cleanup
|
|
132
|
+
if (isRemote && fs.existsSync(videoPath)) {
|
|
133
|
+
try {
|
|
134
|
+
fs.unlinkSync(videoPath);
|
|
135
|
+
}
|
|
136
|
+
catch { }
|
|
137
|
+
}
|
|
138
|
+
if (!fs.existsSync(outputFile)) {
|
|
139
|
+
return `❌ Extraction échouée — aucun fichier audio produit.`;
|
|
140
|
+
}
|
|
141
|
+
const stats = fs.statSync(outputFile);
|
|
142
|
+
// Get audio duration
|
|
143
|
+
let durationStr = 'N/A';
|
|
144
|
+
try {
|
|
145
|
+
const durRaw = runFFprobe([
|
|
146
|
+
'-v', 'quiet',
|
|
147
|
+
'-show_entries', 'format=duration',
|
|
148
|
+
'-of', 'csv=p=0',
|
|
149
|
+
outputFile
|
|
150
|
+
], { timeout: 5000 }).trim();
|
|
151
|
+
const dur = parseFloat(durRaw);
|
|
152
|
+
if (!isNaN(dur))
|
|
153
|
+
durationStr = formatTimestamp(dur);
|
|
154
|
+
}
|
|
155
|
+
catch { }
|
|
156
|
+
return [
|
|
157
|
+
`🎵 Audio Extrait`,
|
|
158
|
+
`━━━━━━━━━━━━━━━━━`,
|
|
159
|
+
`Source: ${isRemote ? args.source : path.basename(videoPath)}`,
|
|
160
|
+
`Format: ${outputFormat.toUpperCase()}`,
|
|
161
|
+
`Durée: ${durationStr}`,
|
|
162
|
+
`Fichier: ${outputFile}`,
|
|
163
|
+
`Taille: ${formatFileSize(stats.size)}`,
|
|
164
|
+
args.start || args.end ? `Plage: ${args.start || '0:00'} → ${args.end || 'fin'}` : '',
|
|
165
|
+
``,
|
|
166
|
+
`Coût: Gratuit (ffmpeg local)`,
|
|
167
|
+
].filter(Boolean).join('\n');
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
if (isRemote && fs.existsSync(videoPath)) {
|
|
171
|
+
try {
|
|
172
|
+
fs.unlinkSync(videoPath);
|
|
173
|
+
}
|
|
174
|
+
catch { }
|
|
175
|
+
}
|
|
176
|
+
return `❌ Erreur extraction audio: ${err.message}`;
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import * as https from 'https';
|
|
6
|
+
import * as http from 'http';
|
|
7
|
+
import { resolveOutputDir, formatFileSize, safeName, formatTimestamp, TOOL_DIRS } from '../shared.js';
|
|
8
|
+
import { hasSystemFFmpeg, getFFmpegInstallInstructions, runFFmpeg, runFFprobe } from '../ffmpeg.js';
|
|
9
|
+
function extractMetadata(videoPath) {
|
|
10
|
+
try {
|
|
11
|
+
// Use ffprobe JSON output for reliable parsing
|
|
12
|
+
const raw = runFFprobe([
|
|
13
|
+
'-v', 'quiet',
|
|
14
|
+
'-print_format', 'json',
|
|
15
|
+
'-show_format',
|
|
16
|
+
'-show_streams',
|
|
17
|
+
videoPath
|
|
18
|
+
]);
|
|
19
|
+
const data = JSON.parse(raw);
|
|
20
|
+
const videoStream = data.streams?.find((s) => s.codec_type === 'video');
|
|
21
|
+
const audioStream = data.streams?.find((s) => s.codec_type === 'audio');
|
|
22
|
+
const format = data.format || {};
|
|
23
|
+
const duration = parseFloat(format.duration || videoStream?.duration || '0');
|
|
24
|
+
const fpsStr = videoStream?.r_frame_rate || '0/1';
|
|
25
|
+
const [fpsNum, fpsDen] = fpsStr.split('/').map(Number);
|
|
26
|
+
const fps = fpsDen ? Math.round((fpsNum / fpsDen) * 100) / 100 : 0;
|
|
27
|
+
const stats = fs.statSync(videoPath);
|
|
28
|
+
return {
|
|
29
|
+
duration,
|
|
30
|
+
durationStr: formatTimestamp(duration),
|
|
31
|
+
width: videoStream?.width || 0,
|
|
32
|
+
height: videoStream?.height || 0,
|
|
33
|
+
fps,
|
|
34
|
+
codec: videoStream?.codec_name || 'unknown',
|
|
35
|
+
bitrate: format.bit_rate ? `${Math.round(parseInt(format.bit_rate) / 1000)} kbps` : 'N/A',
|
|
36
|
+
fileSize: formatFileSize(stats.size),
|
|
37
|
+
hasAudio: !!audioStream,
|
|
38
|
+
audioCodec: audioStream?.codec_name,
|
|
39
|
+
audioSampleRate: audioStream?.sample_rate ? `${audioStream.sample_rate} Hz` : undefined,
|
|
40
|
+
audioChannels: audioStream?.channels,
|
|
41
|
+
format: format.format_name || path.extname(videoPath).slice(1),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function formatMetadataReport(meta, source) {
|
|
49
|
+
const lines = [
|
|
50
|
+
`📋 Métadonnées Vidéo`,
|
|
51
|
+
`━━━━━━━━━━━━━━━━━━━━`,
|
|
52
|
+
`Source: ${source}`,
|
|
53
|
+
`Durée: ${meta.durationStr} (${meta.duration.toFixed(2)}s)`,
|
|
54
|
+
`Résolution: ${meta.width}×${meta.height}`,
|
|
55
|
+
`FPS: ${meta.fps}`,
|
|
56
|
+
`Codec: ${meta.codec}`,
|
|
57
|
+
`Bitrate: ${meta.bitrate}`,
|
|
58
|
+
`Taille: ${meta.fileSize}`,
|
|
59
|
+
`Format: ${meta.format}`,
|
|
60
|
+
];
|
|
61
|
+
if (meta.hasAudio) {
|
|
62
|
+
lines.push(`Audio: ${meta.audioCodec || 'oui'} (${meta.audioSampleRate || 'N/A'}, ${meta.audioChannels || '?'}ch)`);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
lines.push(`Audio: aucun`);
|
|
66
|
+
}
|
|
67
|
+
return lines.join('\n');
|
|
68
|
+
}
|
|
69
|
+
// ─── Video download ─────────────────────────────────────────────────────────
|
|
70
|
+
function downloadVideo(url) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const tempPath = path.join(os.tmpdir(), `video_${Date.now()}.mp4`);
|
|
73
|
+
const proto = url.startsWith('https') ? https : http;
|
|
74
|
+
const req = proto.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; OpenCode-Plugin/6.0)' } }, (res) => {
|
|
75
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
76
|
+
return downloadVideo(res.headers.location).then(resolve).catch(reject);
|
|
77
|
+
}
|
|
78
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
79
|
+
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
80
|
+
}
|
|
81
|
+
const ws = fs.createWriteStream(tempPath);
|
|
82
|
+
res.pipe(ws);
|
|
83
|
+
ws.on('finish', () => { ws.close(); resolve(tempPath); });
|
|
84
|
+
ws.on('error', reject);
|
|
85
|
+
});
|
|
86
|
+
req.on('error', reject);
|
|
87
|
+
req.setTimeout(120000, () => { req.destroy(); reject(new Error('Timeout téléchargement (120s)')); });
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// ─── Frame extraction ───────────────────────────────────────────────────────
|
|
91
|
+
function extractWithSystemFFmpeg(videoPath, outputDir, baseName, options) {
|
|
92
|
+
const outputs = [];
|
|
93
|
+
if (options.at_time) {
|
|
94
|
+
// Single Frame Extraction
|
|
95
|
+
const singleOutput = path.join(outputDir, `${baseName}_at_${options.at_time.replace(/:/g, '-')}.png`);
|
|
96
|
+
runFFmpeg([
|
|
97
|
+
'-y', '-i', videoPath,
|
|
98
|
+
'-ss', options.at_time,
|
|
99
|
+
'-frames:v', '1',
|
|
100
|
+
singleOutput
|
|
101
|
+
], { timeout: 60000 });
|
|
102
|
+
if (fs.existsSync(singleOutput))
|
|
103
|
+
outputs.push(singleOutput);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// Range Extraction
|
|
107
|
+
const fps = options.fps || 1;
|
|
108
|
+
const outputPattern = path.join(outputDir, `${baseName}_%03d.png`);
|
|
109
|
+
const args = ['-y', '-i', videoPath];
|
|
110
|
+
if (options.start)
|
|
111
|
+
args.push('-ss', options.start);
|
|
112
|
+
if (options.end)
|
|
113
|
+
args.push('-to', options.end);
|
|
114
|
+
args.push('-vf', `fps=${fps}`, outputPattern);
|
|
115
|
+
runFFmpeg(args, { timeout: 120000 });
|
|
116
|
+
fs.readdirSync(outputDir)
|
|
117
|
+
.filter(f => f.startsWith(baseName) && f.endsWith('.png'))
|
|
118
|
+
.sort()
|
|
119
|
+
.forEach(f => outputs.push(path.join(outputDir, f)));
|
|
120
|
+
}
|
|
121
|
+
return outputs;
|
|
122
|
+
}
|
|
123
|
+
// ─── Tool Definition ────────────────────────────────────────────────────────
|
|
124
|
+
export const extractFramesTool = tool({
|
|
125
|
+
description: `Extract image frames from a video file or URL, and/or inspect video metadata.
|
|
126
|
+
Can extract a single frame at a specific timestamp, or multiple frames from a time range.
|
|
127
|
+
Set metadata_only=true to just get video info (duration, resolution, fps, codec, audio).
|
|
128
|
+
Requires system ffmpeg (sudo apt install ffmpeg).
|
|
129
|
+
Supports MP4, WebM, AVI, MKV, and other common formats.
|
|
130
|
+
Free to use — no API key needed.`,
|
|
131
|
+
args: {
|
|
132
|
+
source: tool.schema.string().describe('Video file path (absolute) or URL'),
|
|
133
|
+
at_time: tool.schema.string().optional().describe('Extract single frame at timestamp (e.g. "00:00:05" or "5")'),
|
|
134
|
+
start: tool.schema.string().optional().describe('Start time for range extraction (e.g. "00:00:02")'),
|
|
135
|
+
end: tool.schema.string().optional().describe('End time for range extraction (e.g. "00:00:10")'),
|
|
136
|
+
fps: tool.schema.number().min(0.1).max(30).optional().describe('Frames per second for range extraction (default: 1)'),
|
|
137
|
+
filename: tool.schema.string().optional().describe('Base filename prefix. Auto-generated if omitted'),
|
|
138
|
+
output_path: tool.schema.string().optional().describe('Custom output directory (absolute or relative). Default: ~/Downloads/pollinations/frames/'),
|
|
139
|
+
metadata_only: tool.schema.boolean().optional().describe('If true, only return video metadata without extracting frames'),
|
|
140
|
+
},
|
|
141
|
+
async execute(args, context) {
|
|
142
|
+
// Check ffmpeg
|
|
143
|
+
if (!hasSystemFFmpeg()) {
|
|
144
|
+
return [
|
|
145
|
+
`❌ FFmpeg non trouvé!`,
|
|
146
|
+
``,
|
|
147
|
+
`Cet outil nécessite ffmpeg. Installez-le :`,
|
|
148
|
+
getFFmpegInstallInstructions().split('\n').map(l => ` • ${l}`).join('\n')
|
|
149
|
+
].join('\n');
|
|
150
|
+
}
|
|
151
|
+
// Resolve source: URL → download, path → validate
|
|
152
|
+
let videoPath;
|
|
153
|
+
let isRemote = false;
|
|
154
|
+
if (args.source.startsWith('http://') || args.source.startsWith('https://')) {
|
|
155
|
+
isRemote = true;
|
|
156
|
+
context.metadata({ title: `🎬 Téléchargement vidéo...` });
|
|
157
|
+
try {
|
|
158
|
+
videoPath = await downloadVideo(args.source);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
return `❌ Erreur téléchargement: ${err.message}`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
videoPath = args.source;
|
|
166
|
+
if (!fs.existsSync(videoPath)) {
|
|
167
|
+
return `❌ Fichier introuvable: ${videoPath}`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// ─── Metadata mode ─────────────────────────────────────────────
|
|
171
|
+
if (args.metadata_only) {
|
|
172
|
+
const meta = extractMetadata(videoPath);
|
|
173
|
+
if (isRemote && fs.existsSync(videoPath)) {
|
|
174
|
+
try {
|
|
175
|
+
fs.unlinkSync(videoPath);
|
|
176
|
+
}
|
|
177
|
+
catch { }
|
|
178
|
+
}
|
|
179
|
+
if (!meta)
|
|
180
|
+
return `❌ Impossible de lire les métadonnées. Vérifiez que ffprobe est installé.`;
|
|
181
|
+
return formatMetadataReport(meta, isRemote ? args.source : path.basename(videoPath));
|
|
182
|
+
}
|
|
183
|
+
// ─── Frame extraction mode ──────────────────────────────────────
|
|
184
|
+
const outputDir = resolveOutputDir(TOOL_DIRS.frames, args.output_path);
|
|
185
|
+
const baseName = args.filename
|
|
186
|
+
? safeName(args.filename)
|
|
187
|
+
: `frame_${Date.now()}`;
|
|
188
|
+
try {
|
|
189
|
+
context.metadata({ title: `🎬 Extraction frames...` });
|
|
190
|
+
// Get metadata for context
|
|
191
|
+
const meta = extractMetadata(videoPath);
|
|
192
|
+
const extractedFiles = extractWithSystemFFmpeg(videoPath, outputDir, baseName, {
|
|
193
|
+
at_time: args.at_time,
|
|
194
|
+
start: args.start,
|
|
195
|
+
end: args.end,
|
|
196
|
+
fps: args.fps,
|
|
197
|
+
});
|
|
198
|
+
// Cleanup temp video
|
|
199
|
+
if (isRemote && fs.existsSync(videoPath)) {
|
|
200
|
+
try {
|
|
201
|
+
fs.unlinkSync(videoPath);
|
|
202
|
+
}
|
|
203
|
+
catch { }
|
|
204
|
+
}
|
|
205
|
+
if (extractedFiles.length === 0) {
|
|
206
|
+
return `❌ Aucune frame extraite. Vérifiez vos timestamps et la source vidéo.`;
|
|
207
|
+
}
|
|
208
|
+
const totalSize = extractedFiles.reduce((sum, f) => sum + fs.statSync(f).size, 0);
|
|
209
|
+
const fileList = extractedFiles.length <= 5
|
|
210
|
+
? extractedFiles.map(f => ` 📷 ${path.basename(f)}`).join('\n')
|
|
211
|
+
: [
|
|
212
|
+
...extractedFiles.slice(0, 3).map(f => ` 📷 ${path.basename(f)}`),
|
|
213
|
+
` ... et ${extractedFiles.length - 3} de plus`,
|
|
214
|
+
].join('\n');
|
|
215
|
+
const lines = [
|
|
216
|
+
`🎬 Frames Extraites`,
|
|
217
|
+
`━━━━━━━━━━━━━━━━━━━`,
|
|
218
|
+
`Source: ${isRemote ? args.source : path.basename(videoPath)}`,
|
|
219
|
+
];
|
|
220
|
+
// Add video metadata if available
|
|
221
|
+
if (meta) {
|
|
222
|
+
lines.push(`Vidéo: ${meta.width}×${meta.height} • ${meta.fps} fps • ${meta.durationStr}`);
|
|
223
|
+
}
|
|
224
|
+
lines.push(`Frames: ${extractedFiles.length}`, `Dossier: ${outputDir}`, `Taille totale: ${formatFileSize(totalSize)}`, `Fichiers:`, fileList, ``, `Coût: Gratuit (ffmpeg local)`);
|
|
225
|
+
return lines.join('\n');
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
if (isRemote && fs.existsSync(videoPath)) {
|
|
229
|
+
try {
|
|
230
|
+
fs.unlinkSync(videoPath);
|
|
231
|
+
}
|
|
232
|
+
catch { }
|
|
233
|
+
}
|
|
234
|
+
return `❌ Erreur extraction: ${err.message}`;
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
});
|