opencode-pollinations-plugin 6.1.0-beta.9 → 6.2.1

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 (109) hide show
  1. package/README.de.md +130 -0
  2. package/README.es.md +130 -0
  3. package/README.fr.md +130 -0
  4. package/README.it.md +130 -0
  5. package/README.md +87 -73
  6. package/dist/index.js +52 -161
  7. package/dist/locales/de.json +374 -0
  8. package/dist/locales/en.json +373 -0
  9. package/dist/locales/es.json +374 -0
  10. package/dist/locales/fr.json +373 -0
  11. package/dist/locales/index.d.ts +1 -0
  12. package/dist/locales/index.js +37 -0
  13. package/dist/locales/it.json +374 -0
  14. package/dist/server/commands.d.ts +6 -0
  15. package/dist/server/commands.js +394 -125
  16. package/dist/server/config.d.ts +34 -23
  17. package/dist/server/config.js +200 -108
  18. package/dist/server/connect-response.d.ts +2 -0
  19. package/dist/server/connect-response.js +59 -0
  20. package/dist/server/generate-config.d.ts +3 -30
  21. package/dist/server/generate-config.js +164 -106
  22. package/dist/server/index.d.ts +2 -1
  23. package/dist/server/index.js +124 -149
  24. package/dist/server/logger.d.ts +8 -0
  25. package/dist/server/logger.js +38 -0
  26. package/dist/server/models/cache.d.ts +35 -0
  27. package/dist/server/models/cache.js +160 -0
  28. package/dist/server/models/fetcher.d.ts +18 -0
  29. package/dist/server/models/fetcher.js +194 -0
  30. package/dist/server/models/index.d.ts +6 -0
  31. package/dist/server/models/index.js +5 -0
  32. package/dist/server/models/manual.d.ts +15 -0
  33. package/dist/server/models/manual.js +92 -0
  34. package/dist/server/models/types.d.ts +55 -0
  35. package/dist/server/models/types.js +7 -0
  36. package/dist/server/models/worker.d.ts +22 -0
  37. package/dist/server/models/worker.js +174 -0
  38. package/dist/server/pollinations-api.d.ts +11 -0
  39. package/dist/server/pollinations-api.js +21 -8
  40. package/dist/server/proxy.js +222 -307
  41. package/dist/server/quota.d.ts +2 -0
  42. package/dist/server/quota.js +89 -86
  43. package/dist/server/scripts/pollinations_pricing.d.ts +8 -0
  44. package/dist/server/scripts/pollinations_pricing.js +246 -0
  45. package/dist/server/scripts/test_cost_endpoints.d.ts +1 -0
  46. package/dist/server/scripts/test_cost_endpoints.js +61 -0
  47. package/dist/server/scripts/test_dynamic_pricing.d.ts +1 -0
  48. package/dist/server/scripts/test_dynamic_pricing.js +39 -0
  49. package/dist/server/scripts/test_freetier_audit.d.ts +11 -0
  50. package/dist/server/scripts/test_freetier_audit.js +215 -0
  51. package/dist/server/scripts/test_parallel_cost.d.ts +1 -0
  52. package/dist/server/scripts/test_parallel_cost.js +104 -0
  53. package/dist/server/toast.d.ts +7 -1
  54. package/dist/server/toast.js +43 -10
  55. package/dist/tools/design/gen_diagram.d.ts +2 -0
  56. package/dist/tools/design/gen_diagram.js +94 -0
  57. package/dist/tools/design/gen_palette.d.ts +2 -0
  58. package/dist/tools/design/gen_palette.js +182 -0
  59. package/dist/tools/design/gen_qrcode.d.ts +2 -0
  60. package/dist/tools/design/gen_qrcode.js +50 -0
  61. package/dist/tools/ffmpeg.d.ts +24 -0
  62. package/dist/tools/ffmpeg.js +54 -0
  63. package/dist/tools/index.d.ts +25 -0
  64. package/dist/tools/index.js +86 -0
  65. package/dist/tools/pollinations/beta_discovery.d.ts +9 -0
  66. package/dist/tools/pollinations/beta_discovery.js +201 -0
  67. package/dist/tools/pollinations/cost-guard.d.ts +38 -0
  68. package/dist/tools/pollinations/cost-guard.js +136 -0
  69. package/dist/tools/pollinations/deepsearch.d.ts +7 -0
  70. package/dist/tools/pollinations/deepsearch.js +80 -0
  71. package/dist/tools/pollinations/gen_audio.d.ts +18 -0
  72. package/dist/tools/pollinations/gen_audio.js +220 -0
  73. package/dist/tools/pollinations/gen_image.d.ts +11 -0
  74. package/dist/tools/pollinations/gen_image.js +211 -0
  75. package/dist/tools/pollinations/gen_music.d.ts +14 -0
  76. package/dist/tools/pollinations/gen_music.js +157 -0
  77. package/dist/tools/pollinations/gen_video.d.ts +16 -0
  78. package/dist/tools/pollinations/gen_video.js +249 -0
  79. package/dist/tools/pollinations/polli_config.d.ts +2 -0
  80. package/dist/tools/pollinations/polli_config.js +95 -0
  81. package/dist/tools/pollinations/polli_gen_confirm.d.ts +2 -0
  82. package/dist/tools/pollinations/polli_gen_confirm.js +48 -0
  83. package/dist/tools/pollinations/polli_status.d.ts +2 -0
  84. package/dist/tools/pollinations/polli_status.js +31 -0
  85. package/dist/tools/pollinations/polli_web_search.d.ts +15 -0
  86. package/dist/tools/pollinations/polli_web_search.js +126 -0
  87. package/dist/tools/pollinations/search_crawl_scrape.d.ts +7 -0
  88. package/dist/tools/pollinations/search_crawl_scrape.js +85 -0
  89. package/dist/tools/pollinations/shared.d.ts +181 -0
  90. package/dist/tools/pollinations/shared.js +758 -0
  91. package/dist/tools/pollinations/test_estimators.d.ts +1 -0
  92. package/dist/tools/pollinations/test_estimators.js +22 -0
  93. package/dist/tools/pollinations/transcribe_audio.d.ts +13 -0
  94. package/dist/tools/pollinations/transcribe_audio.js +171 -0
  95. package/dist/tools/power/extract_audio.d.ts +2 -0
  96. package/dist/tools/power/extract_audio.js +179 -0
  97. package/dist/tools/power/extract_frames.d.ts +2 -0
  98. package/dist/tools/power/extract_frames.js +237 -0
  99. package/dist/tools/power/file_to_url.d.ts +2 -0
  100. package/dist/tools/power/file_to_url.js +217 -0
  101. package/dist/tools/power/remove_background.d.ts +2 -0
  102. package/dist/tools/power/remove_background.js +404 -0
  103. package/dist/tools/power/rmbg_keys.d.ts +2 -0
  104. package/dist/tools/power/rmbg_keys.js +79 -0
  105. package/dist/tools/shared.d.ts +30 -0
  106. package/dist/tools/shared.js +80 -0
  107. package/package.json +9 -3
  108. package/dist/server/models-seed.d.ts +0 -18
  109. 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,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const extractAudioTool: ToolDefinition;
@@ -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,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const extractFramesTool: ToolDefinition;
@@ -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
+ });
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const fileToUrlTool: ToolDefinition;