opencode-pollinations-plugin 6.0.0 → 6.1.0-beta.10

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.
Files changed (56) hide show
  1. package/README.md +140 -87
  2. package/dist/index.js +33 -154
  3. package/dist/server/commands.d.ts +2 -0
  4. package/dist/server/commands.js +84 -25
  5. package/dist/server/config.d.ts +6 -0
  6. package/dist/server/config.js +4 -1
  7. package/dist/server/generate-config.d.ts +3 -30
  8. package/dist/server/generate-config.js +172 -100
  9. package/dist/server/index.d.ts +2 -1
  10. package/dist/server/index.js +124 -149
  11. package/dist/server/pollinations-api.d.ts +11 -0
  12. package/dist/server/pollinations-api.js +20 -0
  13. package/dist/server/proxy.js +158 -72
  14. package/dist/server/quota.d.ts +8 -0
  15. package/dist/server/quota.js +106 -61
  16. package/dist/server/toast.d.ts +3 -0
  17. package/dist/server/toast.js +16 -0
  18. package/dist/tools/design/gen_diagram.d.ts +2 -0
  19. package/dist/tools/design/gen_diagram.js +94 -0
  20. package/dist/tools/design/gen_palette.d.ts +2 -0
  21. package/dist/tools/design/gen_palette.js +182 -0
  22. package/dist/tools/design/gen_qrcode.d.ts +2 -0
  23. package/dist/tools/design/gen_qrcode.js +50 -0
  24. package/dist/tools/index.d.ts +22 -0
  25. package/dist/tools/index.js +81 -0
  26. package/dist/tools/pollinations/deepsearch.d.ts +7 -0
  27. package/dist/tools/pollinations/deepsearch.js +80 -0
  28. package/dist/tools/pollinations/gen_audio.d.ts +18 -0
  29. package/dist/tools/pollinations/gen_audio.js +204 -0
  30. package/dist/tools/pollinations/gen_image.d.ts +13 -0
  31. package/dist/tools/pollinations/gen_image.js +239 -0
  32. package/dist/tools/pollinations/gen_music.d.ts +14 -0
  33. package/dist/tools/pollinations/gen_music.js +139 -0
  34. package/dist/tools/pollinations/gen_video.d.ts +16 -0
  35. package/dist/tools/pollinations/gen_video.js +222 -0
  36. package/dist/tools/pollinations/search_crawl_scrape.d.ts +7 -0
  37. package/dist/tools/pollinations/search_crawl_scrape.js +85 -0
  38. package/dist/tools/pollinations/shared.d.ts +170 -0
  39. package/dist/tools/pollinations/shared.js +454 -0
  40. package/dist/tools/pollinations/transcribe_audio.d.ts +17 -0
  41. package/dist/tools/pollinations/transcribe_audio.js +235 -0
  42. package/dist/tools/power/extract_audio.d.ts +2 -0
  43. package/dist/tools/power/extract_audio.js +180 -0
  44. package/dist/tools/power/extract_frames.d.ts +2 -0
  45. package/dist/tools/power/extract_frames.js +240 -0
  46. package/dist/tools/power/file_to_url.d.ts +2 -0
  47. package/dist/tools/power/file_to_url.js +217 -0
  48. package/dist/tools/power/remove_background.d.ts +2 -0
  49. package/dist/tools/power/remove_background.js +365 -0
  50. package/dist/tools/power/rmbg_keys.d.ts +2 -0
  51. package/dist/tools/power/rmbg_keys.js +78 -0
  52. package/dist/tools/shared.d.ts +30 -0
  53. package/dist/tools/shared.js +74 -0
  54. package/package.json +9 -3
  55. package/dist/server/models-seed.d.ts +0 -18
  56. package/dist/server/models-seed.js +0 -55
@@ -0,0 +1,235 @@
1
+ /**
2
+ * transcribe_audio Tool - Pollinations Speech-to-Text (STT)
3
+ *
4
+ * Updated: 2026-02-12 - Verified API Reference
5
+ *
6
+ * Two STT options:
7
+ * 1. openai-audio (DEFAULT): GPT-4o Audio Preview - uses /v1/chat/completions with modalities
8
+ * - Least expensive option
9
+ * - Can handle both audio input and output
10
+ *
11
+ * 2. whisper: OpenAI Whisper v3 - uses /v1/audio/transcriptions
12
+ * - POST ONLY with multipart/form-data
13
+ * - Specialized for transcription
14
+ * - Higher accuracy for long audio
15
+ */
16
+ import { tool } from '@opencode-ai/plugin/tool';
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import { getApiKey, httpsPost, httpsPostMultipart, ensureDir, formatFileSize, AUDIO_MODELS, } from './shared.js';
20
+ // ─── Constants ─────────────────────────────────────────────────────────────
21
+ const DEFAULT_MODEL = 'openai-audio';
22
+ const SUPPORTED_FORMATS = ['mp3', 'wav', 'm4a', 'webm', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg'];
23
+ // ─── Tool Definition ──────────────────────────────────────────────────────
24
+ export const transcribeAudioTool = tool({
25
+ description: `Transcribe audio to text using Pollinations AI.
26
+
27
+ **🎙️ Models:**
28
+
29
+ | Model | Endpoint | Best For | Notes |
30
+ |-------|----------|----------|-------|
31
+ | openai-audio | /v1/chat/completions | Short-medium audio | **DEFAULT** - lowest cost |
32
+ | whisper | /v1/audio/transcriptions | Long audio, high accuracy | POST multipart only |
33
+
34
+ **📁 Supported Formats:**
35
+ mp3, wav, m4a, webm, mp4, mpeg, mpga, oga, ogg
36
+
37
+ **💡 Tips:**
38
+ - Use \`openai-audio\` for cost-effective transcription
39
+ - Use \`whisper\` for highest accuracy on long recordings
40
+ - Supports both local files and URLs
41
+
42
+ **📋 Output:**
43
+ - Returns transcribed text
44
+ - Includes detected language (if available)
45
+ - Shows processing time`,
46
+ args: {
47
+ file: tool.schema.string().describe('Path to audio file or URL to transcribe'),
48
+ model: tool.schema.string().optional().describe(`STT model (default: ${DEFAULT_MODEL})`),
49
+ language: tool.schema.string().optional().describe('Language hint (e.g., "en", "fr", "es")'),
50
+ save_transcript: tool.schema.boolean().optional().describe('Save transcript to file (default: false)'),
51
+ },
52
+ async execute(args, context) {
53
+ const apiKey = getApiKey();
54
+ if (!apiKey) {
55
+ return `❌ La transcription nécessite une clé API Pollinations.
56
+ 🔧 Connectez votre clé avec /pollinations connect`;
57
+ }
58
+ const model = args.model || DEFAULT_MODEL;
59
+ // Validate model
60
+ const modelInfo = AUDIO_MODELS[model];
61
+ if (!modelInfo || (modelInfo.type !== 'stt' && modelInfo.type !== 'both')) {
62
+ return `❌ Modèle STT inconnu: ${model}
63
+ 💡 Modèles STT disponibles: ${Object.entries(AUDIO_MODELS)
64
+ .filter(([, info]) => info.type === 'stt' || info.type === 'both')
65
+ .map(([name]) => name)
66
+ .join(', ')}`;
67
+ }
68
+ // Check file
69
+ let audioPath = args.file;
70
+ let audioBuffer;
71
+ let fileName = 'audio.mp3';
72
+ if (audioPath.startsWith('http://') || audioPath.startsWith('https://')) {
73
+ // Download from URL
74
+ context.metadata({ title: `🎙️ STT: Downloading...` });
75
+ try {
76
+ const https = await import('https');
77
+ const http = await import('http');
78
+ const protocol = audioPath.startsWith('https') ? https : http;
79
+ audioBuffer = await new Promise((resolve, reject) => {
80
+ const chunks = [];
81
+ protocol.get(audioPath, (res) => {
82
+ if (res.statusCode === 301 || res.statusCode === 302) {
83
+ // Follow redirect
84
+ const redirectUrl = res.headers.location;
85
+ if (redirectUrl) {
86
+ const redirectProtocol = redirectUrl.startsWith('https') ? https : http;
87
+ redirectProtocol.get(redirectUrl, (res2) => {
88
+ res2.on('data', chunk => chunks.push(chunk));
89
+ res2.on('end', () => resolve(Buffer.concat(chunks)));
90
+ res2.on('error', reject);
91
+ }).on('error', reject);
92
+ return;
93
+ }
94
+ }
95
+ res.on('data', chunk => chunks.push(chunk));
96
+ res.on('end', () => resolve(Buffer.concat(chunks)));
97
+ res.on('error', reject);
98
+ }).on('error', reject);
99
+ });
100
+ // Extract filename from URL
101
+ try {
102
+ const urlPath = new URL(audioPath).pathname;
103
+ fileName = path.basename(urlPath) || 'audio.mp3';
104
+ }
105
+ catch {
106
+ fileName = 'audio.mp3';
107
+ }
108
+ }
109
+ catch (err) {
110
+ return `❌ Impossible de télécharger l'audio: ${err.message}`;
111
+ }
112
+ }
113
+ else {
114
+ // Local file
115
+ if (!fs.existsSync(audioPath)) {
116
+ return `❌ Fichier non trouvé: ${audioPath}`;
117
+ }
118
+ // Check format
119
+ const ext = path.extname(audioPath).toLowerCase().replace('.', '');
120
+ if (!SUPPORTED_FORMATS.includes(ext)) {
121
+ return `⚠️ Format non supporté: .${ext}
122
+ 💡 Formats supportés: ${SUPPORTED_FORMATS.join(', ')}`;
123
+ }
124
+ audioBuffer = fs.readFileSync(audioPath);
125
+ fileName = path.basename(audioPath);
126
+ }
127
+ const fileSize = audioBuffer.length;
128
+ // Metadata
129
+ context.metadata({ title: `🎙️ STT: ${model} (${formatFileSize(fileSize)})` });
130
+ try {
131
+ let transcript = '';
132
+ let detectedLanguage = '';
133
+ if (model === 'openai-audio') {
134
+ // === OpenAI Audio: Use modalities endpoint ===
135
+ // Convert audio to base64
136
+ const base64Audio = audioBuffer.toString('base64');
137
+ const mimeType = fileName.endsWith('.mp3') ? 'audio/mpeg' :
138
+ fileName.endsWith('.wav') ? 'audio/wav' :
139
+ 'audio/mp4';
140
+ const response = await httpsPost('https://gen.pollinations.ai/v1/chat/completions', {
141
+ model: 'openai-audio',
142
+ modalities: ['text', 'audio'],
143
+ messages: [
144
+ {
145
+ role: 'user',
146
+ content: [
147
+ {
148
+ type: 'text',
149
+ text: args.language
150
+ ? `Transcribe this audio to text. Language: ${args.language}`
151
+ : 'Transcribe this audio to text.'
152
+ },
153
+ {
154
+ type: 'input_audio',
155
+ input_audio: {
156
+ data: base64Audio,
157
+ format: fileName.endsWith('.mp3') ? 'mp3' : 'wav'
158
+ }
159
+ }
160
+ ]
161
+ }
162
+ ]
163
+ }, {
164
+ 'Authorization': `Bearer ${apiKey}`,
165
+ });
166
+ const data = JSON.parse(response.data.toString());
167
+ transcript = data.choices?.[0]?.message?.content || '';
168
+ }
169
+ else if (model === 'whisper') {
170
+ // === Whisper: Use multipart endpoint ===
171
+ const fields = {
172
+ file: audioBuffer,
173
+ model: 'whisper',
174
+ };
175
+ if (args.language) {
176
+ fields.language = args.language;
177
+ }
178
+ const response = await httpsPostMultipart('https://gen.pollinations.ai/v1/audio/transcriptions', fields, {
179
+ 'Authorization': `Bearer ${apiKey}`,
180
+ });
181
+ const data = JSON.parse(response.data.toString());
182
+ transcript = data.text || '';
183
+ detectedLanguage = data.language || '';
184
+ }
185
+ if (!transcript) {
186
+ return `❌ Aucune transcription générée.
187
+ 💡 Vérifiez que l'audio contient de la parole claire.`;
188
+ }
189
+ // Build result
190
+ const lines = [
191
+ `🎙️ Transcription Audio`,
192
+ `━━━━━━━━━━━━━━━━━━`,
193
+ `Fichier: ${fileName}`,
194
+ `Taille: ${formatFileSize(fileSize)}`,
195
+ `Modèle: ${model}`,
196
+ ];
197
+ if (detectedLanguage) {
198
+ lines.push(`Langue détectée: ${detectedLanguage}`);
199
+ }
200
+ if (args.language) {
201
+ lines.push(`Langue demandée: ${args.language}`);
202
+ }
203
+ lines.push(``);
204
+ lines.push(`📝 **Transcription:**`);
205
+ lines.push(``);
206
+ lines.push(transcript);
207
+ // Save transcript if requested
208
+ if (args.save_transcript) {
209
+ const outputDir = process.env.HOME
210
+ ? path.join(process.env.HOME, 'Downloads', 'pollinations', 'transcripts')
211
+ : '/tmp';
212
+ ensureDir(outputDir);
213
+ const baseName = path.basename(fileName, path.extname(fileName));
214
+ const outputPath = path.join(outputDir, `${baseName}_transcript.txt`);
215
+ fs.writeFileSync(outputPath, transcript);
216
+ lines.push(``);
217
+ lines.push(`💾 Transcription sauvegardée: ${outputPath}`);
218
+ }
219
+ return lines.join('\n');
220
+ }
221
+ catch (err) {
222
+ if (err.message?.includes('402') || err.message?.includes('Payment')) {
223
+ return `❌ Crédits insuffisants.`;
224
+ }
225
+ if (err.message?.includes('401') || err.message?.includes('403')) {
226
+ return `❌ Clé API invalide ou non autorisée.`;
227
+ }
228
+ if (err.message?.includes('413') || err.message?.includes('too large')) {
229
+ return `❌ Fichier audio trop volumineux.
230
+ 💡 Essayez de compresser ou découper l'audio.`;
231
+ }
232
+ return `❌ Erreur transcription: ${err.message}`;
233
+ }
234
+ },
235
+ });
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const extractAudioTool: ToolDefinition;
@@ -0,0 +1,180 @@
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
+ // ─── Download helper ────────────────────────────────────────────────────────
9
+ function downloadFile(url) {
10
+ return new Promise((resolve, reject) => {
11
+ const ext = path.extname(new URL(url).pathname) || '.mp4';
12
+ const tempPath = path.join(os.tmpdir(), `video_${Date.now()}${ext}`);
13
+ const proto = url.startsWith('https') ? https : http;
14
+ const req = proto.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; OpenCode-Plugin/6.0)' } }, (res) => {
15
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
16
+ return downloadFile(res.headers.location).then(resolve).catch(reject);
17
+ }
18
+ if (res.statusCode && res.statusCode >= 400) {
19
+ return reject(new Error(`HTTP ${res.statusCode}`));
20
+ }
21
+ const ws = fs.createWriteStream(tempPath);
22
+ res.pipe(ws);
23
+ ws.on('finish', () => { ws.close(); resolve(tempPath); });
24
+ ws.on('error', reject);
25
+ });
26
+ req.on('error', reject);
27
+ req.setTimeout(120000, () => { req.destroy(); reject(new Error('Timeout (120s)')); });
28
+ });
29
+ }
30
+ // ─── FFmpeg check ───────────────────────────────────────────────────────────
31
+ function hasSystemFFmpeg() {
32
+ try {
33
+ require('child_process').execSync('ffmpeg -version', { stdio: 'ignore' });
34
+ return true;
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ // ─── Tool Definition ────────────────────────────────────────────────────────
41
+ export const extractAudioTool = tool({
42
+ description: `Extract the audio track from a video file or URL.
43
+ Outputs MP3, WAV, AAC, or FLAC format.
44
+ Can optionally extract only a time range (start/end).
45
+ Requires system ffmpeg installed.
46
+ Free to use — no API key needed.`,
47
+ args: {
48
+ source: tool.schema.string().describe('Video file path (absolute) or URL'),
49
+ format: tool.schema.enum(['mp3', 'wav', 'aac', 'flac']).optional()
50
+ .describe('Output audio format (default: mp3)'),
51
+ start: tool.schema.string().optional()
52
+ .describe('Start time to extract from (e.g. "00:00:10" or "10")'),
53
+ end: tool.schema.string().optional()
54
+ .describe('End time to extract to (e.g. "00:01:30" or "90")'),
55
+ filename: tool.schema.string().optional()
56
+ .describe('Custom output filename (without extension). Auto-generated if omitted'),
57
+ output_path: tool.schema.string().optional()
58
+ .describe('Custom output directory. Default: ~/Downloads/pollinations/audio/'),
59
+ },
60
+ async execute(args, context) {
61
+ if (!hasSystemFFmpeg()) {
62
+ return [
63
+ `❌ FFmpeg non trouvé!`,
64
+ ``,
65
+ `Cet outil nécessite ffmpeg :`,
66
+ ` • Linux: sudo apt install ffmpeg`,
67
+ ` • macOS: brew install ffmpeg`,
68
+ ` • Windows: choco install ffmpeg`,
69
+ ].join('\n');
70
+ }
71
+ // Resolve source
72
+ let videoPath;
73
+ let isRemote = false;
74
+ if (args.source.startsWith('http://') || args.source.startsWith('https://')) {
75
+ isRemote = true;
76
+ context.metadata({ title: `🎵 Téléchargement vidéo...` });
77
+ try {
78
+ videoPath = await downloadFile(args.source);
79
+ }
80
+ catch (err) {
81
+ return `❌ Erreur téléchargement: ${err.message}`;
82
+ }
83
+ }
84
+ else {
85
+ videoPath = args.source;
86
+ if (!fs.existsSync(videoPath)) {
87
+ return `❌ Fichier introuvable: ${videoPath}`;
88
+ }
89
+ }
90
+ // Check if video has audio
91
+ try {
92
+ const { execSync } = require('child_process');
93
+ const probe = execSync(`ffprobe -v quiet -select_streams a -show_entries stream=codec_type -of csv=p=0 "${videoPath}"`, { timeout: 10000, encoding: 'utf-8' }).trim();
94
+ if (!probe) {
95
+ if (isRemote)
96
+ try {
97
+ fs.unlinkSync(videoPath);
98
+ }
99
+ catch { }
100
+ return `❌ Aucune piste audio détectée dans cette vidéo.`;
101
+ }
102
+ }
103
+ catch { }
104
+ const outputFormat = args.format || 'mp3';
105
+ const outputDir = resolveOutputDir(TOOL_DIRS.audio, args.output_path);
106
+ const baseName = args.filename
107
+ ? safeName(args.filename)
108
+ : safeName(path.basename(videoPath, path.extname(videoPath)));
109
+ const outputFile = path.join(outputDir, `${baseName}.${outputFormat}`);
110
+ try {
111
+ context.metadata({ title: `🎵 Extraction audio...` });
112
+ const { execSync } = require('child_process');
113
+ // Build ffmpeg command
114
+ let cmd = `ffmpeg -y -i "${videoPath}" -vn`;
115
+ // Time range
116
+ if (args.start)
117
+ cmd += ` -ss ${args.start}`;
118
+ if (args.end)
119
+ cmd += ` -to ${args.end}`;
120
+ // Format-specific encoding
121
+ switch (outputFormat) {
122
+ case 'mp3':
123
+ cmd += ` -acodec libmp3lame -q:a 2`;
124
+ break;
125
+ case 'wav':
126
+ cmd += ` -acodec pcm_s16le`;
127
+ break;
128
+ case 'aac':
129
+ cmd += ` -acodec aac -b:a 192k`;
130
+ break;
131
+ case 'flac':
132
+ cmd += ` -acodec flac`;
133
+ break;
134
+ }
135
+ cmd += ` "${outputFile}"`;
136
+ execSync(cmd, { stdio: 'ignore', timeout: 120000 });
137
+ // Cleanup
138
+ if (isRemote && fs.existsSync(videoPath)) {
139
+ try {
140
+ fs.unlinkSync(videoPath);
141
+ }
142
+ catch { }
143
+ }
144
+ if (!fs.existsSync(outputFile)) {
145
+ return `❌ Extraction échouée — aucun fichier audio produit.`;
146
+ }
147
+ const stats = fs.statSync(outputFile);
148
+ // Get audio duration
149
+ let durationStr = 'N/A';
150
+ try {
151
+ const durRaw = execSync(`ffprobe -v quiet -show_entries format=duration -of csv=p=0 "${outputFile}"`, { timeout: 5000, encoding: 'utf-8' }).trim();
152
+ const dur = parseFloat(durRaw);
153
+ if (!isNaN(dur))
154
+ durationStr = formatTimestamp(dur);
155
+ }
156
+ catch { }
157
+ return [
158
+ `🎵 Audio Extrait`,
159
+ `━━━━━━━━━━━━━━━━━`,
160
+ `Source: ${isRemote ? args.source : path.basename(videoPath)}`,
161
+ `Format: ${outputFormat.toUpperCase()}`,
162
+ `Durée: ${durationStr}`,
163
+ `Fichier: ${outputFile}`,
164
+ `Taille: ${formatFileSize(stats.size)}`,
165
+ args.start || args.end ? `Plage: ${args.start || '0:00'} → ${args.end || 'fin'}` : '',
166
+ ``,
167
+ `Coût: Gratuit (ffmpeg local)`,
168
+ ].filter(Boolean).join('\n');
169
+ }
170
+ catch (err) {
171
+ if (isRemote && fs.existsSync(videoPath)) {
172
+ try {
173
+ fs.unlinkSync(videoPath);
174
+ }
175
+ catch { }
176
+ }
177
+ return `❌ Erreur extraction audio: ${err.message}`;
178
+ }
179
+ },
180
+ });
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const extractFramesTool: ToolDefinition;
@@ -0,0 +1,240 @@
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
+ function extractMetadata(videoPath) {
9
+ try {
10
+ const { execSync } = require('child_process');
11
+ // Use ffprobe JSON output for reliable parsing
12
+ const probeCmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`;
13
+ const raw = execSync(probeCmd, { timeout: 15000, encoding: 'utf-8' });
14
+ const data = JSON.parse(raw);
15
+ const videoStream = data.streams?.find((s) => s.codec_type === 'video');
16
+ const audioStream = data.streams?.find((s) => s.codec_type === 'audio');
17
+ const format = data.format || {};
18
+ const duration = parseFloat(format.duration || videoStream?.duration || '0');
19
+ const fpsStr = videoStream?.r_frame_rate || '0/1';
20
+ const [fpsNum, fpsDen] = fpsStr.split('/').map(Number);
21
+ const fps = fpsDen ? Math.round((fpsNum / fpsDen) * 100) / 100 : 0;
22
+ const stats = fs.statSync(videoPath);
23
+ return {
24
+ duration,
25
+ durationStr: formatTimestamp(duration),
26
+ width: videoStream?.width || 0,
27
+ height: videoStream?.height || 0,
28
+ fps,
29
+ codec: videoStream?.codec_name || 'unknown',
30
+ bitrate: format.bit_rate ? `${Math.round(parseInt(format.bit_rate) / 1000)} kbps` : 'N/A',
31
+ fileSize: formatFileSize(stats.size),
32
+ hasAudio: !!audioStream,
33
+ audioCodec: audioStream?.codec_name,
34
+ audioSampleRate: audioStream?.sample_rate ? `${audioStream.sample_rate} Hz` : undefined,
35
+ audioChannels: audioStream?.channels,
36
+ format: format.format_name || path.extname(videoPath).slice(1),
37
+ };
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ function formatMetadataReport(meta, source) {
44
+ const lines = [
45
+ `📋 Métadonnées Vidéo`,
46
+ `━━━━━━━━━━━━━━━━━━━━`,
47
+ `Source: ${source}`,
48
+ `Durée: ${meta.durationStr} (${meta.duration.toFixed(2)}s)`,
49
+ `Résolution: ${meta.width}×${meta.height}`,
50
+ `FPS: ${meta.fps}`,
51
+ `Codec: ${meta.codec}`,
52
+ `Bitrate: ${meta.bitrate}`,
53
+ `Taille: ${meta.fileSize}`,
54
+ `Format: ${meta.format}`,
55
+ ];
56
+ if (meta.hasAudio) {
57
+ lines.push(`Audio: ${meta.audioCodec || 'oui'} (${meta.audioSampleRate || 'N/A'}, ${meta.audioChannels || '?'}ch)`);
58
+ }
59
+ else {
60
+ lines.push(`Audio: aucun`);
61
+ }
62
+ return lines.join('\n');
63
+ }
64
+ // ─── Video download ─────────────────────────────────────────────────────────
65
+ function downloadVideo(url) {
66
+ return new Promise((resolve, reject) => {
67
+ const tempPath = path.join(os.tmpdir(), `video_${Date.now()}.mp4`);
68
+ const proto = url.startsWith('https') ? https : http;
69
+ const req = proto.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; OpenCode-Plugin/6.0)' } }, (res) => {
70
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
71
+ return downloadVideo(res.headers.location).then(resolve).catch(reject);
72
+ }
73
+ if (res.statusCode && res.statusCode >= 400) {
74
+ return reject(new Error(`HTTP ${res.statusCode}`));
75
+ }
76
+ const ws = fs.createWriteStream(tempPath);
77
+ res.pipe(ws);
78
+ ws.on('finish', () => { ws.close(); resolve(tempPath); });
79
+ ws.on('error', reject);
80
+ });
81
+ req.on('error', reject);
82
+ req.setTimeout(120000, () => { req.destroy(); reject(new Error('Timeout téléchargement (120s)')); });
83
+ });
84
+ }
85
+ // ─── FFmpeg availability ────────────────────────────────────────────────────
86
+ function hasSystemFFmpeg() {
87
+ try {
88
+ const { execSync } = require('child_process');
89
+ execSync('ffmpeg -version', { stdio: 'ignore' });
90
+ return true;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ // ─── Frame extraction ───────────────────────────────────────────────────────
97
+ function extractWithSystemFFmpeg(videoPath, outputDir, baseName, options) {
98
+ const { execSync } = require('child_process');
99
+ const outputs = [];
100
+ let cmd = `ffmpeg -y -i "${videoPath}"`;
101
+ if (options.at_time) {
102
+ const singleOutput = path.join(outputDir, `${baseName}_at_${options.at_time.replace(/:/g, '-')}.png`);
103
+ cmd += ` -ss ${options.at_time} -frames:v 1 "${singleOutput}"`;
104
+ execSync(cmd, { stdio: 'ignore', timeout: 60000 });
105
+ if (fs.existsSync(singleOutput))
106
+ outputs.push(singleOutput);
107
+ }
108
+ else {
109
+ if (options.start)
110
+ cmd += ` -ss ${options.start}`;
111
+ if (options.end)
112
+ cmd += ` -to ${options.end}`;
113
+ const fps = options.fps || 1;
114
+ const outputPattern = path.join(outputDir, `${baseName}_%03d.png`);
115
+ cmd += ` -vf "fps=${fps}" "${outputPattern}"`;
116
+ execSync(cmd, { stdio: 'ignore', timeout: 120000 });
117
+ fs.readdirSync(outputDir)
118
+ .filter(f => f.startsWith(baseName) && f.endsWith('.png'))
119
+ .sort()
120
+ .forEach(f => outputs.push(path.join(outputDir, f)));
121
+ }
122
+ return outputs;
123
+ }
124
+ // ─── Tool Definition ────────────────────────────────────────────────────────
125
+ export const extractFramesTool = tool({
126
+ description: `Extract image frames from a video file or URL, and/or inspect video metadata.
127
+ Can extract a single frame at a specific timestamp, or multiple frames from a time range.
128
+ Set metadata_only=true to just get video info (duration, resolution, fps, codec, audio).
129
+ Requires system ffmpeg (sudo apt install ffmpeg).
130
+ Supports MP4, WebM, AVI, MKV, and other common formats.
131
+ Free to use — no API key needed.`,
132
+ args: {
133
+ source: tool.schema.string().describe('Video file path (absolute) or URL'),
134
+ at_time: tool.schema.string().optional().describe('Extract single frame at timestamp (e.g. "00:00:05" or "5")'),
135
+ start: tool.schema.string().optional().describe('Start time for range extraction (e.g. "00:00:02")'),
136
+ end: tool.schema.string().optional().describe('End time for range extraction (e.g. "00:00:10")'),
137
+ fps: tool.schema.number().min(0.1).max(30).optional().describe('Frames per second for range extraction (default: 1)'),
138
+ filename: tool.schema.string().optional().describe('Base filename prefix. Auto-generated if omitted'),
139
+ output_path: tool.schema.string().optional().describe('Custom output directory (absolute or relative). Default: ~/Downloads/pollinations/frames/'),
140
+ metadata_only: tool.schema.boolean().optional().describe('If true, only return video metadata without extracting frames'),
141
+ },
142
+ async execute(args, context) {
143
+ // Check ffmpeg
144
+ if (!hasSystemFFmpeg()) {
145
+ return [
146
+ `❌ FFmpeg non trouvé!`,
147
+ ``,
148
+ `Cet outil nécessite ffmpeg. Installez-le :`,
149
+ ` • Linux: sudo apt install ffmpeg`,
150
+ ` • macOS: brew install ffmpeg`,
151
+ ` • Windows: choco install ffmpeg`,
152
+ ].join('\n');
153
+ }
154
+ // Resolve source: URL → download, path → validate
155
+ let videoPath;
156
+ let isRemote = false;
157
+ if (args.source.startsWith('http://') || args.source.startsWith('https://')) {
158
+ isRemote = true;
159
+ context.metadata({ title: `🎬 Téléchargement vidéo...` });
160
+ try {
161
+ videoPath = await downloadVideo(args.source);
162
+ }
163
+ catch (err) {
164
+ return `❌ Erreur téléchargement: ${err.message}`;
165
+ }
166
+ }
167
+ else {
168
+ videoPath = args.source;
169
+ if (!fs.existsSync(videoPath)) {
170
+ return `❌ Fichier introuvable: ${videoPath}`;
171
+ }
172
+ }
173
+ // ─── Metadata mode ─────────────────────────────────────────────
174
+ if (args.metadata_only) {
175
+ const meta = extractMetadata(videoPath);
176
+ if (isRemote && fs.existsSync(videoPath)) {
177
+ try {
178
+ fs.unlinkSync(videoPath);
179
+ }
180
+ catch { }
181
+ }
182
+ if (!meta)
183
+ return `❌ Impossible de lire les métadonnées. Vérifiez que ffprobe est installé.`;
184
+ return formatMetadataReport(meta, isRemote ? args.source : path.basename(videoPath));
185
+ }
186
+ // ─── Frame extraction mode ──────────────────────────────────────
187
+ const outputDir = resolveOutputDir(TOOL_DIRS.frames, args.output_path);
188
+ const baseName = args.filename
189
+ ? safeName(args.filename)
190
+ : `frame_${Date.now()}`;
191
+ try {
192
+ context.metadata({ title: `🎬 Extraction frames...` });
193
+ // Get metadata for context
194
+ const meta = extractMetadata(videoPath);
195
+ const extractedFiles = extractWithSystemFFmpeg(videoPath, outputDir, baseName, {
196
+ at_time: args.at_time,
197
+ start: args.start,
198
+ end: args.end,
199
+ fps: args.fps,
200
+ });
201
+ // Cleanup temp video
202
+ if (isRemote && fs.existsSync(videoPath)) {
203
+ try {
204
+ fs.unlinkSync(videoPath);
205
+ }
206
+ catch { }
207
+ }
208
+ if (extractedFiles.length === 0) {
209
+ return `❌ Aucune frame extraite. Vérifiez vos timestamps et la source vidéo.`;
210
+ }
211
+ const totalSize = extractedFiles.reduce((sum, f) => sum + fs.statSync(f).size, 0);
212
+ const fileList = extractedFiles.length <= 5
213
+ ? extractedFiles.map(f => ` 📷 ${path.basename(f)}`).join('\n')
214
+ : [
215
+ ...extractedFiles.slice(0, 3).map(f => ` 📷 ${path.basename(f)}`),
216
+ ` ... et ${extractedFiles.length - 3} de plus`,
217
+ ].join('\n');
218
+ const lines = [
219
+ `🎬 Frames Extraites`,
220
+ `━━━━━━━━━━━━━━━━━━━`,
221
+ `Source: ${isRemote ? args.source : path.basename(videoPath)}`,
222
+ ];
223
+ // Add video metadata if available
224
+ if (meta) {
225
+ lines.push(`Vidéo: ${meta.width}×${meta.height} • ${meta.fps} fps • ${meta.durationStr}`);
226
+ }
227
+ lines.push(`Frames: ${extractedFiles.length}`, `Dossier: ${outputDir}`, `Taille totale: ${formatFileSize(totalSize)}`, `Fichiers:`, fileList, ``, `Coût: Gratuit (ffmpeg local)`);
228
+ return lines.join('\n');
229
+ }
230
+ catch (err) {
231
+ if (isRemote && fs.existsSync(videoPath)) {
232
+ try {
233
+ fs.unlinkSync(videoPath);
234
+ }
235
+ catch { }
236
+ }
237
+ return `❌ Erreur extraction: ${err.message}`;
238
+ }
239
+ },
240
+ });
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const fileToUrlTool: ToolDefinition;