opencode-pollinations-plugin 6.1.0-beta.2 → 6.1.0-beta.22
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 +242 -62
- package/dist/index.js +68 -159
- package/dist/server/commands.d.ts +6 -0
- package/dist/server/commands.js +400 -71
- package/dist/server/config.d.ts +32 -23
- package/dist/server/config.js +183 -99
- package/dist/server/connect-response.d.ts +2 -0
- package/dist/server/connect-response.js +141 -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 +36 -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 +150 -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 +21 -0
- package/dist/server/models/worker.js +97 -0
- package/dist/server/pollinations-api.d.ts +11 -0
- package/dist/server/pollinations-api.js +21 -8
- package/dist/server/proxy.js +223 -160
- 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 +24 -0
- package/dist/tools/index.js +83 -0
- package/dist/tools/pollinations/beta_discovery.d.ts +9 -0
- package/dist/tools/pollinations/beta_discovery.js +197 -0
- package/dist/tools/pollinations/cost-guard.d.ts +38 -0
- package/dist/tools/pollinations/cost-guard.js +141 -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 +246 -0
- package/dist/tools/pollinations/gen_image.d.ts +11 -0
- package/dist/tools/pollinations/gen_image.js +225 -0
- package/dist/tools/pollinations/gen_music.d.ts +14 -0
- package/dist/tools/pollinations/gen_music.js +180 -0
- package/dist/tools/pollinations/gen_video.d.ts +16 -0
- package/dist/tools/pollinations/gen_video.js +256 -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 +164 -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 +165 -0
- package/dist/tools/pollinations/shared.js +665 -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 +194 -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 +366 -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,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,194 @@
|
|
|
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
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
17
|
+
const DEFAULT_MODEL = 'whisper-large-v3';
|
|
18
|
+
const SUPPORTED_FORMATS = ['mp3', 'wav', 'm4a', 'webm', 'mp4', 'mpeg', 'mpga', 'ogg'];
|
|
19
|
+
// ─── Tool Definition ──────────────────────────────────────────────────────
|
|
20
|
+
export const polliSttTool = tool({
|
|
21
|
+
description: `Transcribe audio to text using Pollinations AI.
|
|
22
|
+
|
|
23
|
+
**🎙️ Models:**
|
|
24
|
+
|
|
25
|
+
| Model | Supplier | Notes |
|
|
26
|
+
|-------|----------|-------|
|
|
27
|
+
| whisper-large-v3 | OpenAI | **DEFAULT** - High accuracy, long audio |
|
|
28
|
+
| whisper-1 | OpenAI | Standard accuracy |
|
|
29
|
+
| scribe | ElevenLabs | Scribe v2 model |
|
|
30
|
+
|
|
31
|
+
**📁 Supported Formats:**
|
|
32
|
+
mp3, wav, m4a, webm, mp4, mpeg, mpga, ogg
|
|
33
|
+
|
|
34
|
+
**💡 Tips:**
|
|
35
|
+
- Use \`whisper-large-v3\` for the highest accuracy on long recordings
|
|
36
|
+
- Supports both local files and URLs
|
|
37
|
+
|
|
38
|
+
**📋 Output:**
|
|
39
|
+
- Returns transcribed text
|
|
40
|
+
- Includes detected language (if available)
|
|
41
|
+
- Shows processing time`,
|
|
42
|
+
args: {
|
|
43
|
+
file: tool.schema.string().describe('Path to audio file or URL to transcribe'),
|
|
44
|
+
model: tool.schema.string().optional().describe(`STT model (default: ${DEFAULT_MODEL})`),
|
|
45
|
+
language: tool.schema.string().optional().describe('Language hint (e.g., "en", "fr", "es")'),
|
|
46
|
+
save_transcript: tool.schema.boolean().optional().describe('Save transcript to file (default: false)'),
|
|
47
|
+
},
|
|
48
|
+
async execute(args, context) {
|
|
49
|
+
const apiKey = getApiKey();
|
|
50
|
+
if (!apiKey) {
|
|
51
|
+
return `❌ La transcription nécessite une clé API Pollinations.
|
|
52
|
+
🔧 Connectez votre clé avec /pollinations connect`;
|
|
53
|
+
}
|
|
54
|
+
const model = args.model || DEFAULT_MODEL;
|
|
55
|
+
// Validate model
|
|
56
|
+
const audioModels = getAudioModels();
|
|
57
|
+
const modelInfo = audioModels[model];
|
|
58
|
+
if (!modelInfo || (modelInfo.type !== 'stt' && modelInfo.type !== 'both')) {
|
|
59
|
+
return `❌ Modèle STT inconnu: ${model}
|
|
60
|
+
💡 Modèles STT disponibles: ${Object.entries(audioModels)
|
|
61
|
+
.filter(([, info]) => info.type === 'stt' || info.type === 'both')
|
|
62
|
+
.map(([name]) => name)
|
|
63
|
+
.join(', ')}`;
|
|
64
|
+
}
|
|
65
|
+
// Check file
|
|
66
|
+
let audioPath = args.file;
|
|
67
|
+
let audioBuffer;
|
|
68
|
+
let fileName = 'audio.mp3';
|
|
69
|
+
if (audioPath.startsWith('http://') || audioPath.startsWith('https://')) {
|
|
70
|
+
// Download from URL
|
|
71
|
+
context.metadata({ title: `🎙️ STT: Downloading...` });
|
|
72
|
+
try {
|
|
73
|
+
const https = await import('https');
|
|
74
|
+
const http = await import('http');
|
|
75
|
+
const protocol = audioPath.startsWith('https') ? https : http;
|
|
76
|
+
audioBuffer = await new Promise((resolve, reject) => {
|
|
77
|
+
const chunks = [];
|
|
78
|
+
protocol.get(audioPath, (res) => {
|
|
79
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
80
|
+
// Follow redirect
|
|
81
|
+
const redirectUrl = res.headers.location;
|
|
82
|
+
if (redirectUrl) {
|
|
83
|
+
const redirectProtocol = redirectUrl.startsWith('https') ? https : http;
|
|
84
|
+
redirectProtocol.get(redirectUrl, (res2) => {
|
|
85
|
+
res2.on('data', chunk => chunks.push(chunk));
|
|
86
|
+
res2.on('end', () => resolve(Buffer.concat(chunks)));
|
|
87
|
+
res2.on('error', reject);
|
|
88
|
+
}).on('error', reject);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
res.on('data', chunk => chunks.push(chunk));
|
|
93
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
94
|
+
res.on('error', reject);
|
|
95
|
+
}).on('error', reject);
|
|
96
|
+
});
|
|
97
|
+
// Extract filename from URL
|
|
98
|
+
try {
|
|
99
|
+
const urlPath = new URL(audioPath).pathname;
|
|
100
|
+
fileName = path.basename(urlPath) || 'audio.mp3';
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
fileName = 'audio.mp3';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
return `❌ Impossible de télécharger l'audio: ${err.message}`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// Local file
|
|
112
|
+
if (!fs.existsSync(audioPath)) {
|
|
113
|
+
return `❌ Fichier non trouvé: ${audioPath}`;
|
|
114
|
+
}
|
|
115
|
+
// Check format
|
|
116
|
+
const ext = path.extname(audioPath).toLowerCase().replace('.', '');
|
|
117
|
+
if (!SUPPORTED_FORMATS.includes(ext)) {
|
|
118
|
+
return `⚠️ Format non supporté: .${ext}
|
|
119
|
+
💡 Formats supportés: ${SUPPORTED_FORMATS.join(', ')}`;
|
|
120
|
+
}
|
|
121
|
+
audioBuffer = fs.readFileSync(audioPath);
|
|
122
|
+
fileName = path.basename(audioPath);
|
|
123
|
+
}
|
|
124
|
+
const fileSize = audioBuffer.length;
|
|
125
|
+
// Metadata
|
|
126
|
+
context.metadata({ title: `🎙️ STT: ${model} (${formatFileSize(fileSize)})` });
|
|
127
|
+
try {
|
|
128
|
+
let transcript = '';
|
|
129
|
+
let detectedLanguage = '';
|
|
130
|
+
// === All STT models use multipart endpoint ===
|
|
131
|
+
const fields = {
|
|
132
|
+
file: audioBuffer,
|
|
133
|
+
model: model,
|
|
134
|
+
};
|
|
135
|
+
if (args.language) {
|
|
136
|
+
fields.language = args.language;
|
|
137
|
+
}
|
|
138
|
+
const response = await httpsPostMultipart('https://gen.pollinations.ai/v1/audio/transcriptions', fields, {
|
|
139
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
140
|
+
});
|
|
141
|
+
const data = JSON.parse(response.data.toString());
|
|
142
|
+
transcript = data.text || '';
|
|
143
|
+
detectedLanguage = data.language || '';
|
|
144
|
+
if (!transcript) {
|
|
145
|
+
return `❌ Aucune transcription générée.
|
|
146
|
+
💡 Vérifiez que l'audio contient de la parole claire.`;
|
|
147
|
+
}
|
|
148
|
+
// Build result
|
|
149
|
+
const lines = [
|
|
150
|
+
`🎙️ Transcription Audio`,
|
|
151
|
+
`━━━━━━━━━━━━━━━━━━`,
|
|
152
|
+
`Fichier: ${fileName}`,
|
|
153
|
+
`Taille: ${formatFileSize(fileSize)}`,
|
|
154
|
+
`Modèle: ${model}`,
|
|
155
|
+
];
|
|
156
|
+
if (detectedLanguage) {
|
|
157
|
+
lines.push(`Langue détectée: ${detectedLanguage}`);
|
|
158
|
+
}
|
|
159
|
+
if (args.language) {
|
|
160
|
+
lines.push(`Langue demandée: ${args.language}`);
|
|
161
|
+
}
|
|
162
|
+
lines.push(``);
|
|
163
|
+
lines.push(`📝 **Transcription:**`);
|
|
164
|
+
lines.push(``);
|
|
165
|
+
lines.push(transcript);
|
|
166
|
+
// Save transcript if requested
|
|
167
|
+
if (args.save_transcript) {
|
|
168
|
+
const outputDir = process.env.HOME
|
|
169
|
+
? path.join(process.env.HOME, 'Downloads', 'pollinations', 'transcripts')
|
|
170
|
+
: '/tmp';
|
|
171
|
+
ensureDir(outputDir);
|
|
172
|
+
const baseName = path.basename(fileName, path.extname(fileName));
|
|
173
|
+
const outputPath = path.join(outputDir, `${baseName}_transcript.txt`);
|
|
174
|
+
fs.writeFileSync(outputPath, transcript);
|
|
175
|
+
lines.push(``);
|
|
176
|
+
lines.push(`💾 Transcription sauvegardée: ${outputPath}`);
|
|
177
|
+
}
|
|
178
|
+
return lines.join('\n');
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
if (err.message?.includes('402') || err.message?.includes('Payment')) {
|
|
182
|
+
return `❌ Crédits insuffisants.`;
|
|
183
|
+
}
|
|
184
|
+
if (err.message?.includes('401') || err.message?.includes('403')) {
|
|
185
|
+
return `❌ Clé API invalide ou non autorisée.`;
|
|
186
|
+
}
|
|
187
|
+
if (err.message?.includes('413') || err.message?.includes('too large')) {
|
|
188
|
+
return `❌ Fichier audio trop volumineux.
|
|
189
|
+
💡 Essayez de compresser ou découper l'audio.`;
|
|
190
|
+
}
|
|
191
|
+
return `❌ Erreur transcription: ${err.message}`;
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
});
|
|
@@ -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
|
+
});
|