opencode-pollinations-plugin 6.0.0-beta.25 β†’ 6.0.0-beta.5

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.
@@ -0,0 +1,94 @@
1
+ import { tool } from '@opencode-ai/plugin/tool';
2
+ import * as https from 'https';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { resolveOutputDir, TOOL_DIRS } from '../shared.js';
6
+ const MERMAID_INK_BASE = 'https://mermaid.ink';
7
+ /**
8
+ * Encode Mermaid code for mermaid.ink API
9
+ * Uses base64 encoding of the diagram definition
10
+ */
11
+ function encodeMermaid(code) {
12
+ return Buffer.from(code, 'utf-8').toString('base64url');
13
+ }
14
+ /**
15
+ * Fetch binary content from URL
16
+ */
17
+ function fetchBinary(url) {
18
+ return new Promise((resolve, reject) => {
19
+ const req = https.get(url, { headers: { 'User-Agent': 'OpenCode-Pollinations-Plugin/6.0' } }, (res) => {
20
+ // Follow redirects
21
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
22
+ return fetchBinary(res.headers.location).then(resolve).catch(reject);
23
+ }
24
+ if (res.statusCode && res.statusCode >= 400) {
25
+ return reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
26
+ }
27
+ const chunks = [];
28
+ res.on('data', (chunk) => chunks.push(chunk));
29
+ res.on('end', () => resolve(Buffer.concat(chunks)));
30
+ });
31
+ req.on('error', reject);
32
+ req.setTimeout(15000, () => {
33
+ req.destroy();
34
+ reject(new Error('Timeout fetching diagram'));
35
+ });
36
+ });
37
+ }
38
+ export const genDiagramTool = tool({
39
+ description: `Render a Mermaid diagram to SVG or PNG image.
40
+ Uses mermaid.ink (free, no auth required). Supports all Mermaid syntax:
41
+ flowchart, sequenceDiagram, classDiagram, stateDiagram, erDiagram, gantt, pie, mindmap, timeline, etc.
42
+ The diagram code should be valid Mermaid syntax WITHOUT the \`\`\`mermaid fences.`,
43
+ args: {
44
+ code: tool.schema.string().describe('Mermaid diagram code (e.g. "graph LR; A-->B; B-->C")'),
45
+ format: tool.schema.enum(['svg', 'png']).optional().describe('Output format (default: svg)'),
46
+ theme: tool.schema.enum(['default', 'dark', 'forest', 'neutral']).optional().describe('Diagram theme (default: default)'),
47
+ filename: tool.schema.string().optional().describe('Custom filename (without extension). Auto-generated if omitted'),
48
+ output_path: tool.schema.string().optional().describe('Custom output directory. Default: ~/Downloads/pollinations/diagrams/'),
49
+ },
50
+ async execute(args, context) {
51
+ const format = args.format || 'svg';
52
+ const theme = args.theme || 'default';
53
+ const outputDir = resolveOutputDir(TOOL_DIRS.diagrams, args.output_path);
54
+ // Build mermaid.ink URL
55
+ // For themed rendering, we wrap with config
56
+ const themedCode = theme !== 'default'
57
+ ? `%%{init: {'theme': '${theme}'}}%%\n${args.code}`
58
+ : args.code;
59
+ const encoded = encodeMermaid(themedCode);
60
+ const endpoint = format === 'svg' ? 'svg' : 'img';
61
+ const url = `${MERMAID_INK_BASE}/${endpoint}/${encoded}`;
62
+ // Generate filename
63
+ const safeName = args.filename
64
+ ? args.filename.replace(/[^a-zA-Z0-9_-]/g, '_')
65
+ : `diagram_${Date.now()}`;
66
+ const filePath = path.join(outputDir, `${safeName}.${format}`);
67
+ try {
68
+ const data = await fetchBinary(url);
69
+ if (data.length < 50) {
70
+ return `❌ Diagram Error: mermaid.ink returned empty/invalid response. Check your Mermaid syntax.`;
71
+ }
72
+ fs.writeFileSync(filePath, data);
73
+ const fileSizeKB = (data.length / 1024).toFixed(1);
74
+ // Extract diagram type from first line
75
+ const firstLine = args.code.trim().split('\n')[0].trim();
76
+ const diagramType = firstLine.replace(/[;\s{].*/g, '');
77
+ context.metadata({ title: `πŸ“Š Diagram: ${diagramType}` });
78
+ return [
79
+ `πŸ“Š Diagram Rendered`,
80
+ `━━━━━━━━━━━━━━━━━━━`,
81
+ `Type: ${diagramType}`,
82
+ `Theme: ${theme}`,
83
+ `Format: ${format.toUpperCase()}`,
84
+ `File: ${filePath}`,
85
+ `Weight: ${fileSizeKB} KB`,
86
+ `URL: ${url}`,
87
+ `Cost: Free (mermaid.ink)`,
88
+ ].join('\n');
89
+ }
90
+ catch (err) {
91
+ return `❌ Diagram Error: ${err.message}\nπŸ’‘ Verify your Mermaid syntax at https://mermaid.live`;
92
+ }
93
+ },
94
+ });
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const genPaletteTool: ToolDefinition;
@@ -0,0 +1,182 @@
1
+ import { tool } from '@opencode-ai/plugin/tool';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { resolveOutputDir, TOOL_DIRS } from '../shared.js';
5
+ function hexToHSL(hex) {
6
+ hex = hex.replace('#', '');
7
+ if (hex.length === 3)
8
+ hex = hex.split('').map(c => c + c).join('');
9
+ const r = parseInt(hex.substring(0, 2), 16) / 255;
10
+ const g = parseInt(hex.substring(2, 4), 16) / 255;
11
+ const b = parseInt(hex.substring(4, 6), 16) / 255;
12
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
13
+ let h = 0, s = 0;
14
+ const l = (max + min) / 2;
15
+ if (max !== min) {
16
+ const d = max - min;
17
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
18
+ switch (max) {
19
+ case r:
20
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
21
+ break;
22
+ case g:
23
+ h = ((b - r) / d + 2) / 6;
24
+ break;
25
+ case b:
26
+ h = ((r - g) / d + 4) / 6;
27
+ break;
28
+ }
29
+ }
30
+ return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
31
+ }
32
+ function hslToHex(h, s, l) {
33
+ s /= 100;
34
+ l /= 100;
35
+ const a = s * Math.min(l, 1 - l);
36
+ const f = (n) => {
37
+ const k = (n + h / 30) % 12;
38
+ const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
39
+ return Math.round(255 * color).toString(16).padStart(2, '0');
40
+ };
41
+ return `#${f(0)}${f(8)}${f(4)}`;
42
+ }
43
+ function generatePalette(baseHex, scheme, count) {
44
+ const base = hexToHSL(baseHex);
45
+ const colors = [];
46
+ switch (scheme) {
47
+ case 'complementary':
48
+ colors.push({ hex: baseHex, role: 'Base' });
49
+ colors.push({ hex: hslToHex((base.h + 180) % 360, base.s, base.l), role: 'Complement' });
50
+ // Fill shades
51
+ for (let i = 2; i < count; i++) {
52
+ const lShift = base.l + (i % 2 === 0 ? 15 : -15) * Math.ceil(i / 2);
53
+ colors.push({ hex: hslToHex(base.h, base.s, Math.max(10, Math.min(90, lShift))), role: `Shade ${i - 1}` });
54
+ }
55
+ break;
56
+ case 'analogous':
57
+ for (let i = 0; i < count; i++) {
58
+ const offset = (i - Math.floor(count / 2)) * 30;
59
+ colors.push({
60
+ hex: hslToHex((base.h + offset + 360) % 360, base.s, base.l),
61
+ role: offset === 0 ? 'Base' : `${offset > 0 ? '+' : ''}${offset}Β°`
62
+ });
63
+ }
64
+ break;
65
+ case 'triadic':
66
+ colors.push({ hex: baseHex, role: 'Base' });
67
+ colors.push({ hex: hslToHex((base.h + 120) % 360, base.s, base.l), role: 'Triad +120Β°' });
68
+ colors.push({ hex: hslToHex((base.h + 240) % 360, base.s, base.l), role: 'Triad +240Β°' });
69
+ for (let i = 3; i < count; i++) {
70
+ const lShift = base.l + (i % 2 === 0 ? 12 : -12) * Math.ceil((i - 2) / 2);
71
+ colors.push({ hex: hslToHex((base.h + (i * 120)) % 360, base.s, Math.max(10, Math.min(90, lShift))), role: `Accent ${i - 2}` });
72
+ }
73
+ break;
74
+ case 'split-complementary':
75
+ colors.push({ hex: baseHex, role: 'Base' });
76
+ colors.push({ hex: hslToHex((base.h + 150) % 360, base.s, base.l), role: 'Split +150Β°' });
77
+ colors.push({ hex: hslToHex((base.h + 210) % 360, base.s, base.l), role: 'Split +210Β°' });
78
+ for (let i = 3; i < count; i++) {
79
+ colors.push({ hex: hslToHex(base.h, base.s, Math.max(10, Math.min(90, base.l + (i * 10 - 30)))), role: `Tone ${i - 2}` });
80
+ }
81
+ break;
82
+ case 'monochromatic':
83
+ default:
84
+ for (let i = 0; i < count; i++) {
85
+ const l = Math.round(15 + (i / (count - 1)) * 70); // 15% to 85%
86
+ colors.push({
87
+ hex: hslToHex(base.h, base.s, l),
88
+ role: l < base.l ? `Dark ${Math.abs(i - Math.floor(count / 2))}` : l === base.l ? 'Base' : `Light ${Math.abs(i - Math.floor(count / 2))}`,
89
+ });
90
+ }
91
+ // Mark closest to base
92
+ let closestIdx = 0;
93
+ let closestDiff = Infinity;
94
+ colors.forEach((c, i) => {
95
+ const diff = Math.abs(hexToHSL(c.hex).l - base.l);
96
+ if (diff < closestDiff) {
97
+ closestDiff = diff;
98
+ closestIdx = i;
99
+ }
100
+ });
101
+ colors[closestIdx].role = 'Base';
102
+ break;
103
+ }
104
+ return colors.slice(0, count);
105
+ }
106
+ function generateSVG(colors) {
107
+ const swatchW = 120;
108
+ const swatchH = 80;
109
+ const gap = 8;
110
+ const totalW = colors.length * (swatchW + gap) - gap + 40;
111
+ const totalH = swatchH + 60;
112
+ const swatches = colors.map((c, i) => {
113
+ const x = 20 + i * (swatchW + gap);
114
+ const textColor = hexToHSL(c.hex).l > 50 ? '#1a1a1a' : '#ffffff';
115
+ return `
116
+ <rect x="${x}" y="20" width="${swatchW}" height="${swatchH}" rx="8" fill="${c.hex}" stroke="#333" stroke-width="1"/>
117
+ <text x="${x + swatchW / 2}" y="${swatchH / 2 + 15}" text-anchor="middle" fill="${textColor}" font-family="monospace" font-size="13" font-weight="bold">${c.hex.toUpperCase()}</text>
118
+ <text x="${x + swatchW / 2}" y="${swatchH + 38}" text-anchor="middle" fill="#666" font-family="sans-serif" font-size="11">${c.role}</text>`;
119
+ }).join('');
120
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="${totalH}" viewBox="0 0 ${totalW} ${totalH}">
121
+ <rect width="100%" height="100%" fill="#0d0d0d" rx="12"/>
122
+ ${swatches}
123
+ </svg>`;
124
+ }
125
+ export const genPaletteTool = tool({
126
+ description: `Generate a harmonious color palette from a base hex color.
127
+ Outputs a visual SVG palette + JSON color codes. Works 100% offline.
128
+ Schemes: monochromatic, complementary, analogous, triadic, split-complementary.
129
+ Perfect for frontend design, branding, and UI theming.`,
130
+ args: {
131
+ color: tool.schema.string().describe('Base hex color (e.g. "#3B82F6" or "3B82F6")'),
132
+ scheme: tool.schema.enum(['monochromatic', 'complementary', 'analogous', 'triadic', 'split-complementary']).optional()
133
+ .describe('Color harmony scheme (default: analogous)'),
134
+ count: tool.schema.number().min(3).max(8).optional().describe('Number of colors (default: 5, max: 8)'),
135
+ filename: tool.schema.string().optional().describe('Custom filename (without extension). Auto-generated if omitted'),
136
+ output_path: tool.schema.string().optional().describe('Custom output directory. Default: ~/Downloads/pollinations/palettes/'),
137
+ },
138
+ async execute(args, context) {
139
+ const scheme = args.scheme || 'analogous';
140
+ const count = args.count || 5;
141
+ // Normalize hex
142
+ let hex = args.color.trim();
143
+ if (!hex.startsWith('#'))
144
+ hex = '#' + hex;
145
+ if (!/^#[0-9a-fA-F]{3,6}$/.test(hex)) {
146
+ return `❌ Invalid hex color: "${args.color}". Use format: #3B82F6 or 3B82F6`;
147
+ }
148
+ if (hex.length === 4)
149
+ hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
150
+ // Generate palette
151
+ const colors = generatePalette(hex, scheme, count);
152
+ const outputDir = resolveOutputDir(TOOL_DIRS.palettes, args.output_path);
153
+ // Save SVG
154
+ const safeName = args.filename
155
+ ? args.filename.replace(/[^a-zA-Z0-9_-]/g, '_')
156
+ : `palette_${hex.replace('#', '')}_${scheme}`;
157
+ const svgPath = path.join(outputDir, `${safeName}.svg`);
158
+ const svg = generateSVG(colors);
159
+ fs.writeFileSync(svgPath, svg);
160
+ // Build CSS custom properties snippet
161
+ const cssVars = colors.map((c, i) => ` --color-${i + 1}: ${c.hex};`).join('\n');
162
+ context.metadata({ title: `🎨 Palette: ${scheme} from ${hex}` });
163
+ const colorTable = colors.map(c => ` ${c.hex.toUpperCase()} ${c.role}`).join('\n');
164
+ return [
165
+ `🎨 Color Palette Generated`,
166
+ `━━━━━━━━━━━━━━━━━━━━━━━━━`,
167
+ `Base: ${hex.toUpperCase()}`,
168
+ `Scheme: ${scheme}`,
169
+ `Colors (${count}):`,
170
+ colorTable,
171
+ ``,
172
+ `File: ${svgPath}`,
173
+ ``,
174
+ `CSS Variables:`,
175
+ `:root {`,
176
+ cssVars,
177
+ `}`,
178
+ ``,
179
+ `Cost: Free (local computation)`,
180
+ ].join('\n');
181
+ },
182
+ });
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from '@opencode-ai/plugin/tool';
2
+ export declare const genQrcodeTool: ToolDefinition;
@@ -0,0 +1,50 @@
1
+ import { tool } from '@opencode-ai/plugin/tool';
2
+ import * as QRCode from 'qrcode';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { resolveOutputDir, TOOL_DIRS } from '../shared.js';
6
+ export const genQrcodeTool = tool({
7
+ description: `Generate a QR code image from text, URL, or WiFi credentials.
8
+ Outputs a PNG file saved locally. Works 100% offline, no API key needed.
9
+ Examples: URLs, plain text, WiFi (format: WIFI:T:WPA;S:NetworkName;P:Password;;)`,
10
+ args: {
11
+ content: tool.schema.string().describe('The text, URL, or WiFi string to encode into a QR code'),
12
+ size: tool.schema.number().min(128).max(2048).optional().describe('QR code size in pixels (default: 512)'),
13
+ filename: tool.schema.string().optional().describe('Custom filename (without extension). Auto-generated if omitted'),
14
+ output_path: tool.schema.string().optional().describe('Custom output directory. Default: ~/Downloads/pollinations/qrcodes/'),
15
+ },
16
+ async execute(args, context) {
17
+ const size = args.size || 512;
18
+ const outputDir = resolveOutputDir(TOOL_DIRS.qrcodes, args.output_path);
19
+ const safeName = args.filename
20
+ ? args.filename.replace(/[^a-zA-Z0-9_-]/g, '_')
21
+ : `qr_${Date.now()}`;
22
+ const filePath = path.join(outputDir, `${safeName}.png`);
23
+ try {
24
+ await QRCode.toFile(filePath, args.content, {
25
+ width: size,
26
+ margin: 2,
27
+ color: { dark: '#000000', light: '#ffffff' },
28
+ errorCorrectionLevel: 'M',
29
+ });
30
+ const stats = fs.statSync(filePath);
31
+ const fileSizeKB = (stats.size / 1024).toFixed(1);
32
+ const displayContent = args.content.length > 80
33
+ ? args.content.substring(0, 77) + '...'
34
+ : args.content;
35
+ context.metadata({ title: `πŸ”² QR Code: ${displayContent}` });
36
+ return [
37
+ `πŸ”² QR Code GΓ©nΓ©rΓ©`,
38
+ `━━━━━━━━━━━━━━━━━━`,
39
+ `Contenu: ${displayContent}`,
40
+ `Taille: ${size}Γ—${size}px`,
41
+ `Fichier: ${filePath}`,
42
+ `Poids: ${fileSizeKB} KB`,
43
+ `CoΓ»t: Gratuit (gΓ©nΓ©ration locale)`,
44
+ ].join('\n');
45
+ }
46
+ catch (err) {
47
+ return `❌ Erreur QR Code: ${err.message}`;
48
+ }
49
+ },
50
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Tool Registry β€” Conditional Injection System
3
+ *
4
+ * Free Universe (no key): 7 tools always available
5
+ * Enter Universe (with key): +5 Pollinations tools
6
+ *
7
+ * Tools are injected ONCE at plugin init. Restart needed after /poll connect.
8
+ */
9
+ /**
10
+ * Build the tool registry based on user's access level
11
+ *
12
+ * @returns Record<string, Tool> to be spread into the plugin's tool: {} property
13
+ */
14
+ export declare function createToolRegistry(): Record<string, any>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Tool Registry β€” Conditional Injection System
3
+ *
4
+ * Free Universe (no key): 7 tools always available
5
+ * Enter Universe (with key): +5 Pollinations tools
6
+ *
7
+ * Tools are injected ONCE at plugin init. Restart needed after /poll connect.
8
+ */
9
+ import { loadConfig } from '../server/config.js';
10
+ // === FREE TOOLS (Always available) ===
11
+ import { genQrcodeTool } from './design/gen_qrcode.js';
12
+ import { genDiagramTool } from './design/gen_diagram.js';
13
+ import { genPaletteTool } from './design/gen_palette.js';
14
+ import { fileToUrlTool } from './power/file_to_url.js';
15
+ import { removeBackgroundTool } from './power/remove_background.js';
16
+ import { extractFramesTool } from './power/extract_frames.js';
17
+ import { extractAudioTool } from './power/extract_audio.js';
18
+ import { rmbgKeysTool } from './power/rmbg_keys.js';
19
+ // === ENTER TOOLS (Require API key) ===
20
+ // Phase 4D: Pollinations tools β€” TO BE IMPLEMENTED
21
+ // import { genImageTool } from './pollinations/gen_image.js';
22
+ // import { genVideoTool } from './pollinations/gen_video.js';
23
+ // import { genAudioTool } from './pollinations/gen_audio.js';
24
+ // import { genMusicTool } from './pollinations/gen_music.js';
25
+ // import { deepsearchTool } from './pollinations/deepsearch.js';
26
+ // import { searchCrawlScrapeTool } from './pollinations/search_crawl_scrape.js';
27
+ import * as fs from 'fs';
28
+ const LOG_FILE = '/tmp/opencode_pollinations_v4.log';
29
+ function log(msg) {
30
+ try {
31
+ fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [Tools] ${msg}\n`);
32
+ }
33
+ catch { }
34
+ }
35
+ /**
36
+ * Detect if a valid API key is present
37
+ */
38
+ function hasValidKey() {
39
+ const config = loadConfig();
40
+ return !!(config.apiKey && config.apiKey.length > 5 && config.apiKey !== 'dummy');
41
+ }
42
+ /**
43
+ * Build the tool registry based on user's access level
44
+ *
45
+ * @returns Record<string, Tool> to be spread into the plugin's tool: {} property
46
+ */
47
+ export function createToolRegistry() {
48
+ const tools = {};
49
+ const keyPresent = hasValidKey();
50
+ const config = loadConfig();
51
+ // === FREE UNIVERSE: Always injected ===
52
+ // Design tools
53
+ tools['gen_qrcode'] = genQrcodeTool;
54
+ tools['gen_diagram'] = genDiagramTool;
55
+ tools['gen_palette'] = genPaletteTool;
56
+ // Power tools
57
+ tools['file_to_url'] = fileToUrlTool;
58
+ tools['remove_background'] = removeBackgroundTool;
59
+ tools['extract_frames'] = extractFramesTool;
60
+ tools['extract_audio'] = extractAudioTool;
61
+ tools['rmbg_keys'] = rmbgKeysTool;
62
+ // gen_image (free version) β€” TODO Phase 4D
63
+ // tools['gen_image'] = genImageTool;
64
+ log(`Free tools injected: ${Object.keys(tools).length}`);
65
+ // === ENTER UNIVERSE: Only with valid API key ===
66
+ if (keyPresent) {
67
+ // Pollinations paid tools β€” TODO Phase 4D
68
+ // tools['gen_video'] = genVideoTool;
69
+ // tools['gen_audio'] = genAudioTool;
70
+ // tools['gen_music'] = genMusicTool;
71
+ // tools['deepsearch'] = deepsearchTool;
72
+ // tools['search_crawl_scrape'] = searchCrawlScrapeTool;
73
+ log(`Enter tools injected (key detected). Total: ${Object.keys(tools).length}`);
74
+ }
75
+ else {
76
+ log(`Enter tools SKIPPED (no key). Total: ${Object.keys(tools).length}`);
77
+ }
78
+ return tools;
79
+ }
@@ -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;