Last active
October 18, 2025 08:14
-
-
Save weskerty/9789dee090b6c3f0cdf99e756169d0b3 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const fs = require('fs').promises; | |
| const path = require('path'); | |
| const { promisify } = require('util'); | |
| const { exec: execCallback } = require('child_process'); | |
| const { bot } = require('../lib'); | |
| const exec = promisify(execCallback); | |
| const utils = { | |
| delay: (ms) => new Promise(resolve => setTimeout(resolve, ms)), | |
| formatSize(sizeInBytes) { | |
| if (!sizeInBytes || isNaN(sizeInBytes)) return "Desconocido"; | |
| const mb = sizeInBytes / (1024 * 1024); | |
| return mb < 1024 ? `${mb.toFixed(2)} MB` : `${(mb / 1024).toFixed(2)} GB`; | |
| }, | |
| formatTime(seconds) { | |
| if (!seconds || isNaN(seconds)) return "0"; | |
| const mins = Math.floor(seconds / 60); | |
| const secs = Math.floor(seconds % 60); | |
| return `${mins}:${secs.toString().padStart(2, '0')}`; | |
| } | |
| }; | |
| class MessageUtils { | |
| static getVideoDetails(quotedMessage) { | |
| try { | |
| const fullMessage = quotedMessage.message?.message || quotedMessage; | |
| if (fullMessage?.videoMessage) { | |
| return { | |
| isVideo: true, | |
| mimetype: fullMessage.videoMessage.mimetype || 'video/mp4', | |
| fileLength: fullMessage.videoMessage.fileLength ? parseInt(fullMessage.videoMessage.fileLength) : null | |
| }; | |
| } | |
| if (fullMessage?.documentMessage) { | |
| const mimetype = fullMessage.documentMessage.mimetype || ''; | |
| return { | |
| isVideo: mimetype.startsWith('video/'), | |
| mimetype: fullMessage.documentMessage.mimetype, | |
| fileLength: fullMessage.documentMessage.fileLength ? parseInt(fullMessage.documentMessage.fileLength) : null | |
| }; | |
| } | |
| return { isVideo: false }; | |
| } catch (error) { | |
| console.error('VIDEOPLUGIN: Error getting video details:', error); | |
| return { isVideo: false }; | |
| } | |
| } | |
| } | |
| class VideoProcessor { | |
| constructor() { | |
| this.config = null; | |
| } | |
| setContext(ctx) { | |
| this.config = { | |
| tempDir: ctx.TEMP_DOWNLOAD_DIR || path.join(process.cwd(), 'tmp'), | |
| maxFileSize: (parseInt(ctx.MAX_UPLOAD, 10) * 1048576) || 1500000000, | |
| maxTotalDuration: 900, | |
| outputFormat: 'mp4', | |
| segmentDuration: 30, | |
| sendDelay: 1000 | |
| }; | |
| } | |
| async safeExecute(command) { | |
| try { | |
| return await exec(command); | |
| } catch (error) { | |
| console.error(`VIDEOPLUGIN: Command failed: ${command}`); | |
| console.error(`VIDEOPLUGIN: Error: ${error.message}`); | |
| throw error; | |
| } | |
| } | |
| async isFFmpegAvailable() { | |
| try { | |
| await exec('ffmpeg -version'); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| async isVaapiAvailable() { | |
| try { | |
| const command = 'ffmpeg -hide_banner -hwaccels'; | |
| const result = await exec(command); | |
| return result.stdout.includes('vaapi'); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| async getVideoDuration(filePath) { | |
| try { | |
| const command = `ffprobe -v quiet -show_entries format=duration -of csv=p=0 "${filePath}"`; | |
| const result = await this.safeExecute(command); | |
| return parseFloat(result.stdout.trim()); | |
| } catch (error) { | |
| console.error(`VIDEOPLUGIN: Error getting duration: ${error.message}`); | |
| return null; | |
| } | |
| } | |
| async getVideoInfo(filePath) { | |
| try { | |
| const command = `ffprobe -i "${filePath}"`; | |
| const result = await exec(command); | |
| return result.stderr || result.stdout; | |
| } catch (error) { | |
| return error.stderr || error.stdout || error.message; | |
| } | |
| } | |
| async splitVideo(inputPath, outputDir, segmentDuration = 30) { | |
| const outputPattern = path.join(outputDir, 'segment_%03d.mp4'); | |
| const vaapiAvailable = await this.isVaapiAvailable(); | |
| let command; | |
| if (vaapiAvailable) { | |
| command = [ | |
| 'ffmpeg', | |
| '-hwaccel', 'vaapi', | |
| '-hwaccel_device', '/dev/dri/renderD128', | |
| '-hwaccel_output_format', 'vaapi', | |
| '-i', `"${inputPath}"`, | |
| '-vf', 'scale_vaapi=format=nv12', | |
| '-c:v', 'h264_vaapi', | |
| '-c:a', 'aac', | |
| '-b:a', '128k', | |
| '-map', '0', | |
| '-segment_time', segmentDuration.toString(), | |
| '-f', 'segment', | |
| '-reset_timestamps', '1', | |
| '-avoid_negative_ts', 'make_zero', | |
| `"${outputPattern}"` | |
| ].join(' '); | |
| } else { | |
| command = [ | |
| 'ffmpeg', | |
| '-i', `"${inputPath}"`, | |
| '-c:v', 'libx264', | |
| '-preset', 'fast', | |
| '-c:a', 'aac', | |
| '-b:a', '128k', | |
| '-map', '0', | |
| '-segment_time', segmentDuration.toString(), | |
| '-f', 'segment', | |
| '-reset_timestamps', '1', | |
| '-avoid_negative_ts', 'make_zero', | |
| `"${outputPattern}"` | |
| ].join(' '); | |
| } | |
| try { | |
| await this.safeExecute(command); | |
| } catch (error) { | |
| if (vaapiAvailable) { | |
| const fallbackCommand = [ | |
| 'ffmpeg', | |
| '-i', `"${inputPath}"`, | |
| '-c:v', 'libx264', | |
| '-preset', 'fast', | |
| '-c:a', 'aac', | |
| '-b:a', '128k', | |
| '-map', '0', | |
| '-segment_time', segmentDuration.toString(), | |
| '-f', 'segment', | |
| '-reset_timestamps', '1', | |
| '-avoid_negative_ts', 'make_zero', | |
| `"${outputPattern}"` | |
| ].join(' '); | |
| await this.safeExecute(fallbackCommand); | |
| } else { | |
| throw error; | |
| } | |
| } | |
| const files = await fs.readdir(outputDir); | |
| return files | |
| .filter(file => file.startsWith('segment_') && file.endsWith('.mp4')) | |
| .sort() | |
| .map(file => path.join(outputDir, file)); | |
| } | |
| async compressVideo(inputPath, outputPath) { | |
| const vaapiAvailable = await this.isVaapiAvailable(); | |
| let command; | |
| if (vaapiAvailable) { | |
| command = [ | |
| 'ffmpeg', | |
| '-hwaccel', 'vaapi', | |
| '-hwaccel_device', '/dev/dri/renderD128', | |
| '-hwaccel_output_format', 'vaapi', | |
| '-i', `"${inputPath}"`, | |
| '-vf', 'scale_vaapi=format=nv12', | |
| '-c:v', 'h264_vaapi', | |
| '-b:v', '512k', | |
| '-an', | |
| `"${outputPath}"` | |
| ].join(' '); | |
| } else { | |
| command = [ | |
| 'ffmpeg', | |
| '-i', `"${inputPath}"`, | |
| '-c:v', 'libx264', | |
| '-preset', 'fast', | |
| '-crf', '28', | |
| '-an', | |
| `"${outputPath}"` | |
| ].join(' '); | |
| } | |
| try { | |
| await this.safeExecute(command); | |
| } catch (error) { | |
| if (vaapiAvailable) { | |
| const fallbackCommand = [ | |
| 'ffmpeg', | |
| '-i', `"${inputPath}"`, | |
| '-c:v', 'libx264', | |
| '-preset', 'fast', | |
| '-crf', '28', | |
| '-an', | |
| `"${outputPath}"` | |
| ].join(' '); | |
| await this.safeExecute(fallbackCommand); | |
| } else { | |
| throw error; | |
| } | |
| } | |
| } | |
| } | |
| const videoProcessor = new VideoProcessor(); | |
| bot( | |
| { | |
| pattern: '30s ?(.*)', | |
| fromMe: true, | |
| desc: 'Split quoted video into 30-second segments', | |
| type: 'media', | |
| }, | |
| async (message, match, ctx) => { | |
| videoProcessor.setContext(ctx); | |
| const reply = async (text, isError = false) => { | |
| await message.send(`${isError ? '❌' : '✅'} ${text}`, { quoted: message.data }); | |
| }; | |
| try { | |
| const quotedMessage = message.reply_message; | |
| if (!quotedMessage) { | |
| return await reply('Debes citar un video para dividir en partes de 30 segundos', true); | |
| } | |
| const videoDetails = MessageUtils.getVideoDetails(quotedMessage); | |
| if (!videoDetails.isVideo) { | |
| return await reply('El archivo citado no es video', true); | |
| } | |
| if (videoDetails.fileLength && videoDetails.fileLength > videoProcessor.config.maxFileSize) { | |
| return await reply( | |
| `Video demasiado grande (${utils.formatSize(videoDetails.fileLength)}). Máximo: ${utils.formatSize(videoProcessor.config.maxFileSize)}`, | |
| true | |
| ); | |
| } | |
| if (!await videoProcessor.isFFmpegAvailable()) { | |
| return await reply('FFmpeg no instalado', true); | |
| } | |
| const sessionId = `split30s_${Date.now()}`; | |
| const tempDir = path.join(videoProcessor.config.tempDir, sessionId); | |
| try { | |
| await fs.mkdir(tempDir, { recursive: true }); | |
| const mediaBuffer = await quotedMessage.downloadMediaMessage(); | |
| if (!mediaBuffer) { | |
| return await reply('No se pudo descargar el video citado', true); | |
| } | |
| const tempVideoPath = path.join(tempDir, 'input_video.mp4'); | |
| await fs.writeFile(tempVideoPath, mediaBuffer); | |
| const duration = await videoProcessor.getVideoDuration(tempVideoPath); | |
| if (duration && duration > videoProcessor.config.maxTotalDuration) { | |
| return await reply( | |
| `Video demasiado largo (${utils.formatTime(duration)}). Máximo permitido: ${utils.formatTime(videoProcessor.config.maxTotalDuration)}`, | |
| true | |
| ); | |
| } | |
| const segmentPaths = await videoProcessor.splitVideo(tempVideoPath, tempDir, videoProcessor.config.segmentDuration); | |
| if (segmentPaths.length === 0) { | |
| return await reply('No se pudieron generar segmentos', true); | |
| } | |
| for (let i = 0; i < segmentPaths.length; i++) { | |
| const segmentPath = segmentPaths[i]; | |
| const segmentNumber = i + 1; | |
| try { | |
| const segmentBuffer = await fs.readFile(segmentPath); | |
| const fileName = `parte_${segmentNumber.toString().padStart(2, '0')}_de_${segmentPaths.length}.mp4`; | |
| const startTime = i * videoProcessor.config.segmentDuration; | |
| const endTime = Math.min((i + 1) * videoProcessor.config.segmentDuration, duration || (i + 1) * videoProcessor.config.segmentDuration); | |
| await message.send( | |
| segmentBuffer, | |
| { | |
| fileName: fileName, | |
| mimetype: 'video/mp4', | |
| quoted: message.data, | |
| caption: `📹 Parte ${segmentNumber}/${segmentPaths.length}\n⏱️ ${utils.formatTime(startTime)} - ${utils.formatTime(endTime)}` | |
| }, | |
| 'video' | |
| ); | |
| if (i < segmentPaths.length - 1) { | |
| await utils.delay(videoProcessor.config.sendDelay); | |
| } | |
| } catch (sendError) { | |
| console.error(`VIDEOPLUGIN: Error sending segment ${segmentNumber}: ${sendError.message}`); | |
| await reply(`Error enviando parte ${segmentNumber}: ${sendError.message}`, true); | |
| } | |
| } | |
| } catch (error) { | |
| console.error(`VIDEOPLUGIN: Error processing video: ${error.message}`); | |
| await reply(`Error procesando video: ${error.message}`, true); | |
| } finally { | |
| await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); | |
| } | |
| } catch (error) { | |
| console.error('VIDEOPLUGIN: error:', error); | |
| await reply(`Error: ${error.message}`, true); | |
| } | |
| } | |
| ); | |
| bot( | |
| { | |
| pattern: 'metainfo ?(.*)', | |
| fromMe: true, | |
| desc: 'Get video metadata using ffprobe', | |
| type: 'media', | |
| }, | |
| async (message, match, ctx) => { | |
| videoProcessor.setContext(ctx); | |
| const reply = async (text, isError = false) => { | |
| await message.send(`${isError ? '❌' : '✅'} ${text}`, { quoted: message.data }); | |
| }; | |
| try { | |
| const quotedMessage = message.reply_message; | |
| if (!quotedMessage) { | |
| return await reply('Debes citar un video para obtener su metadata', true); | |
| } | |
| const videoDetails = MessageUtils.getVideoDetails(quotedMessage); | |
| if (!videoDetails.isVideo) { | |
| return await reply('El archivo citado no es video', true); | |
| } | |
| if (!await videoProcessor.isFFmpegAvailable()) { | |
| return await reply('FFmpeg no instalado', true); | |
| } | |
| const sessionId = `metainfo_${Date.now()}`; | |
| const tempDir = path.join(videoProcessor.config.tempDir, sessionId); | |
| try { | |
| await fs.mkdir(tempDir, { recursive: true }); | |
| const mediaBuffer = await quotedMessage.downloadMediaMessage(); | |
| if (!mediaBuffer) { | |
| return await reply('No se pudo descargar el video citado', true); | |
| } | |
| const tempVideoPath = path.join(tempDir, 'video.mp4'); | |
| await fs.writeFile(tempVideoPath, mediaBuffer); | |
| const info = await videoProcessor.getVideoInfo(tempVideoPath); | |
| await message.send(`📊 *Metadata del video:*\n\`\`\`\n${info}\n\`\`\``, { quoted: message.data }); | |
| } catch (error) { | |
| console.error(`VIDEOPLUGIN: Error getting metadata: ${error.message}`); | |
| await reply(`Error obteniendo metadata: ${error.message}`, true); | |
| } finally { | |
| await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); | |
| } | |
| } catch (error) { | |
| console.error('VIDEOPLUGIN: error:', error); | |
| await reply(`Error: ${error.message}`, true); | |
| } | |
| } | |
| ); | |
| bot( | |
| { | |
| pattern: 'lite ?(.*)', | |
| fromMe: true, | |
| desc: 'Compress video to 50% size, h264, no audio', | |
| type: 'media', | |
| }, | |
| async (message, match, ctx) => { | |
| videoProcessor.setContext(ctx); | |
| const reply = async (text, isError = false) => { | |
| await message.send(`${isError ? '❌' : '✅'} ${text}`, { quoted: message.data }); | |
| }; | |
| try { | |
| const quotedMessage = message.reply_message; | |
| if (!quotedMessage) { | |
| return await reply('Debes citar un video para comprimir', true); | |
| } | |
| const videoDetails = MessageUtils.getVideoDetails(quotedMessage); | |
| if (!videoDetails.isVideo) { | |
| return await reply('El archivo citado no es video', true); | |
| } | |
| if (videoDetails.fileLength && videoDetails.fileLength > videoProcessor.config.maxFileSize) { | |
| return await reply( | |
| `Video demasiado grande (${utils.formatSize(videoDetails.fileLength)}). Máximo: ${utils.formatSize(videoProcessor.config.maxFileSize)}`, | |
| true | |
| ); | |
| } | |
| if (!await videoProcessor.isFFmpegAvailable()) { | |
| return await reply('FFmpeg no instalado', true); | |
| } | |
| const sessionId = `lite_${Date.now()}`; | |
| const tempDir = path.join(videoProcessor.config.tempDir, sessionId); | |
| try { | |
| await fs.mkdir(tempDir, { recursive: true }); | |
| const mediaBuffer = await quotedMessage.downloadMediaMessage(); | |
| if (!mediaBuffer) { | |
| return await reply('No se pudo descargar el video citado', true); | |
| } | |
| const tempVideoPath = path.join(tempDir, 'input_video.mp4'); | |
| const outputVideoPath = path.join(tempDir, 'output_lite.mp4'); | |
| await fs.writeFile(tempVideoPath, mediaBuffer); | |
| await videoProcessor.compressVideo(tempVideoPath, outputVideoPath); | |
| const outputBuffer = await fs.readFile(outputVideoPath); | |
| const outputStats = await fs.stat(outputVideoPath); | |
| const originalSize = videoDetails.fileLength || mediaBuffer.length; | |
| const compressedSize = outputStats.size; | |
| const reduction = ((1 - (compressedSize / originalSize)) * 100).toFixed(2); | |
| await message.send( | |
| outputBuffer, | |
| { | |
| fileName: 'video_lite.mp4', | |
| mimetype: 'video/mp4', | |
| quoted: message.data, | |
| caption: `🗜️ *Video comprimido*\n📦 Original: ${utils.formatSize(originalSize)}\n📉 Comprimido: ${utils.formatSize(compressedSize)}\n💾 Reducción: ${reduction}%` | |
| }, | |
| 'video' | |
| ); | |
| } catch (error) { | |
| console.error(`VIDEOPLUGIN: Error compressing video: ${error.message}`); | |
| await reply(`Error comprimiendo video: ${error.message}`, true); | |
| } finally { | |
| await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); | |
| } | |
| } catch (error) { | |
| console.error('VIDEOPLUGIN: error:', error); | |
| await reply(`Error: ${error.message}`, true); | |
| } | |
| } | |
| ); | |
| module.exports = { VideoProcessor }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment