Spaces:
Running
Running
| const express = require('express'); | |
| const cors = require('cors'); | |
| const multer = require('multer'); | |
| const { promises: fs } = require('fs'); | |
| const path = require('path'); | |
| const { exec } = require('child_process'); | |
| const { TextToSpeechClient } = require('@google-cloud/text-to-speech'); | |
| const sharp = require('sharp'); | |
| const axios = require('axios'); | |
| const uuid = require('uuid'); | |
| const app = express(); | |
| app.use(cors()); | |
| app.use(express.json()); | |
| const upload = multer({ dest: 'temp_uploads/' }); | |
| // Usar solo GEMINI_API_KEY para todo, y buscar 'gemini_api' en minúsculas también. | |
| const GEMINI_API_KEY = process.env.GEMINI_API_KEY || process.env.gemini_api || ''; | |
| const TTS_CREDENTIALS = process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON ? JSON.parse(process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON) : null; | |
| let ttsClient; | |
| if (TTS_CREDENTIALS) { | |
| ttsClient = new TextToSpeechClient({ credentials: TTS_CREDENTIALS }); | |
| } else { | |
| // Si no hay credenciales JSON explícitas, intentará usar GOOGLE_APPLICATION_CREDENTIALS | |
| // o el entorno de ejecución predeterminado de Google Cloud. | |
| ttsClient = new TextToSpeechClient(); | |
| } | |
| const TEMP_DIR = path.join(__dirname, 'temp_files'); | |
| async function ensureDir(dir) { | |
| try { | |
| await fs.mkdir(dir, { recursive: true }); | |
| } catch (error) { | |
| if (error.code !== 'EEXIST') { | |
| throw error; | |
| } | |
| } | |
| } | |
| async function enhanceTextWithGemini(textInput) { | |
| try { | |
| const chatHistory = [{ | |
| role: "user", | |
| parts: [{ text: `Mejora y profesionaliza el siguiente texto para un noticiero, hazlo conciso y atractivo, sin añadir introducciones ni despedidas: '${textInput}'` }] | |
| }]; | |
| const payload = { contents: chatHistory }; | |
| const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}`; | |
| const response = await axios.post(apiUrl, payload); | |
| if (response.data.candidates && response.data.candidates.length > 0 && | |
| response.data.candidates[0].content && response.data.candidates[0].content.parts && | |
| response.data.candidates[0].content.parts.length > 0) { | |
| return response.data.candidates[0].content.parts[0].text; | |
| } else { | |
| console.warn('Respuesta inesperada de Gemini para mejora de texto.'); | |
| return textInput; | |
| } | |
| } catch (error) { | |
| console.error('Error en la llamada a la API de Gemini para texto:', error.message); | |
| return textInput; | |
| } | |
| } | |
| async function generateImageWithGemini(prompt, aspectRatio) { | |
| try { | |
| const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/imagen-3.0-generate-002:predict?key=${GEMINI_API_KEY}`; // Usando GEMINI_API_KEY aquí | |
| const payload = { | |
| instances: { prompt: prompt }, | |
| parameters: { sampleCount: 1 } | |
| }; | |
| const response = await axios.post(apiUrl, payload); | |
| if (response.data.predictions && response.data.predictions.length > 0 && response.data.predictions[0].bytesBase64Encoded) { | |
| const base64Image = response.data.predictions[0].bytesBase64Encoded; | |
| const imgBuffer = Buffer.from(base64Image, 'base64'); | |
| const outputFilename = path.join(TEMP_DIR, `generated_image_${uuid.v4()}.png`); | |
| let targetWidth, targetHeight; | |
| if (aspectRatio === "16:9") { | |
| targetWidth = 1280; | |
| targetHeight = 720; | |
| } else { | |
| targetWidth = 720; | |
| targetHeight = 1280; | |
| } | |
| await sharp(imgBuffer) | |
| .resize(targetWidth, targetHeight, { | |
| fit: sharp.fit.cover, | |
| position: sharp.strategy.attention | |
| }) | |
| .toFile(outputFilename); | |
| return outputFilename; | |
| } else { | |
| throw new Error('Respuesta inesperada de la API de Imagen: no se encontró base64_image.'); | |
| } | |
| } catch (error) { | |
| console.error('Error al generar imagen con Gemini:', error.message); | |
| return null; | |
| } | |
| } | |
| async function processUploadedImage(filePath, aspectRatio) { | |
| try { | |
| let targetWidth, targetHeight; | |
| if (aspectRatio === "16:9") { | |
| targetWidth = 1280; | |
| targetHeight = 720; | |
| } else { | |
| targetWidth = 720; | |
| targetHeight = 1280; | |
| } | |
| const outputFilename = path.join(TEMP_DIR, `processed_uploaded_image_${uuid.v4()}${path.extname(filePath)}`); | |
| await sharp(filePath) | |
| .resize(targetWidth, targetHeight, { | |
| fit: sharp.fit.cover, | |
| position: sharp.strategy.attention | |
| }) | |
| .toFile(outputFilename); | |
| await fs.unlink(filePath); | |
| return outputFilename; | |
| } catch (error) { | |
| console.error('Error al procesar imagen subida:', error.message); | |
| return null; | |
| } | |
| } | |
| async function textToSpeech(text, langCode, service) { | |
| const outputFilePath = path.join(TEMP_DIR, `audio_${uuid.v4()}.mp3`); | |
| try { | |
| if (service === 'gtts') { | |
| const [languageCode, regionCode] = langCode.split('-'); | |
| const request = { | |
| input: { text: text }, | |
| voice: { languageCode: langCode, name: `${languageCode}-Standard-A` }, | |
| audioConfig: { audioEncoding: 'MP3' }, | |
| }; | |
| const [response] = await ttsClient.synthesizeSpeech(request); | |
| await fs.writeFile(outputFilePath, response.audioContent, 'binary'); | |
| return outputFilePath; | |
| } else if (service === 'edge') { | |
| console.warn('Servicio Edge TTS no implementado. Usando TTS de Google Cloud como fallback.'); | |
| const [languageCode, regionCode] = langCode.split('-'); | |
| const request = { | |
| input: { text: text }, | |
| voice: { languageCode: langCode, name: `${languageCode}-Standard-A` }, | |
| audioConfig: { audioEncoding: 'MP3' }, | |
| }; | |
| const [response] = await ttsClient.synthesizeSpeech(request); | |
| await fs.writeFile(outputFilePath, response.audioContent, 'binary'); | |
| return outputFilePath; | |
| } else { | |
| throw new Error('Servicio de voz no soportado.'); | |
| } | |
| } catch (error) { | |
| console.error(`Error al generar TTS para "${text.substring(0, 50)}...":`, error.message); | |
| return null; | |
| } | |
| } | |
| async function getAudioDuration(audioPath) { | |
| return new Promise((resolve, reject) => { | |
| exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${audioPath}"`, (error, stdout, stderr) => { | |
| if (error) { | |
| console.error(`Error al obtener duración del audio: ${stderr}`); | |
| return resolve(0); | |
| } | |
| resolve(parseFloat(stdout)); | |
| }); | |
| }); | |
| } | |
| app.post('/test-voice', async (req, res) => { | |
| await ensureDir(TEMP_DIR); | |
| const { text, lang, service } = req.body; | |
| try { | |
| const audioFilePath = await textToSpeech(text, lang, service); | |
| if (audioFilePath && await fs.access(audioFilePath).then(() => true).catch(() => false)) { | |
| res.download(audioFilePath, path.basename(audioFilePath), async (err) => { | |
| if (err) { | |
| console.error('Error enviando archivo de audio:', err); | |
| res.status(500).json({ error: 'Fallo al enviar archivo de audio de prueba.' }); | |
| } | |
| try { | |
| await fs.unlink(audioFilePath); | |
| } catch (cleanupErr) { | |
| console.error('Error al limpiar archivo de audio de prueba:', cleanupErr); | |
| } | |
| }); | |
| } else { | |
| res.status(500).json({ error: 'Fallo al generar voz de prueba.' }); | |
| } | |
| } catch (error) { | |
| console.error('Error en /test-voice:', error); | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.post('/generate-video', upload.array('photos'), async (req, res) => { | |
| await ensureDir(TEMP_DIR); | |
| const { script, useLlmForScript, aspectRatio, imageDuration, shouldGenerateImages, imageGenerationPrompt, voiceService, voiceLanguage } = req.body; | |
| let uploadedFiles = req.files || []; | |
| try { | |
| if (uploadedFiles.length === 0 && shouldGenerateImages !== 'true') { | |
| return res.status(400).json({ error: 'Por favor, sube al menos una foto O marca la opción "Generar imágenes con IA" y proporciona un prompt.' }); | |
| } | |
| if (uploadedFiles.length > 0 && shouldGenerateImages === 'true') { | |
| return res.status(400).json({ error: 'Conflicto: Has subido fotos Y activado la generación de imágenes con IA. Por favor, elige solo una opción.' }); | |
| } | |
| if (shouldGenerateImages === 'true' && !imageGenerationPrompt) { | |
| return res.status(400).json({ error: 'Para generar imágenes con IA, debes proporcionar un prompt descriptivo.' }); | |
| } | |
| if (!script && useLlmForScript !== 'true') { | |
| return res.status(400).json({ error: 'Por favor, escribe un guion para tu noticia O marca la opción "Usar LLM para generar/mejorar el guion".' }); | |
| } | |
| let finalScript = script; | |
| if (useLlmForScript === 'true' && script) { | |
| console.log("Mejorando guion con Gemini Flash..."); | |
| finalScript = await enhanceTextWithGemini(script); | |
| console.log("Guion mejorado:", finalScript); | |
| } else if (!finalScript) { | |
| console.warn("No se pudo obtener un guion final. Usando un placeholder."); | |
| finalScript = "Noticia importante: Hoy exploramos un tema fascinante. Manténgase al tanto para más actualizaciones."; | |
| } | |
| let scriptSegments = finalScript.split('\n').map(s => s.trim()).filter(s => s); | |
| if (scriptSegments.length === 0) { | |
| scriptSegments = ["Una noticia sin descripción. Más información a continuación."]; | |
| } | |
| const imagePaths = []; | |
| if (uploadedFiles.length > 0) { | |
| console.log(`Procesando ${uploadedFiles.length} imágenes subidas...`); | |
| if (uploadedFiles.length > 10) { | |
| return res.status(400).json({ error: "Solo se permite un máximo de 10 fotos." }); | |
| } | |
| for (const file of uploadedFiles) { | |
| const processedPath = await processUploadedImage(file.path, aspectRatio); | |
| if (processedPath) { | |
| imagePaths.push(processedPath); | |
| } else { | |
| console.error(`Error al procesar imagen ${file.originalname}.`); | |
| } | |
| } | |
| } else if (shouldGenerateImages === 'true') { | |
| console.log("Generando imágenes con IA (Imagen 3.0)..."); | |
| const numImagesToGenerate = Math.min(scriptSegments.length > 0 ? scriptSegments.length : 1, 10); | |
| for (let i = 0; i < numImagesToGenerate; i++) { | |
| const currentPrompt = numImagesToGenerate > 1 ? `${imageGenerationPrompt} - Parte ${i + 1} de la noticia.` : imageGenerationPrompt; | |
| const generatedImgPath = await generateImageWithGemini(currentPrompt, aspectRatio); | |
| if (generatedImgPath) { | |
| imagePaths.push(generatedImgPath); | |
| } else { | |
| console.error(`Error al generar la imagen ${i + 1}.`); | |
| } | |
| } | |
| } | |
| if (imagePaths.length === 0) { | |
| return res.status(500).json({ error: 'No se pudieron obtener imágenes válidas para el video (ni subidas ni generadas).' }); | |
| } | |
| let effectiveScriptSegments = [...scriptSegments]; | |
| if (effectiveScriptSegments.length < imagePaths.length) { | |
| while (effectiveScriptSegments.length < imagePaths.length) { | |
| effectiveScriptSegments.push("Más información sobre esta imagen."); | |
| } | |
| } else if (effectiveScriptSegments.length > imagePaths.length) { | |
| effectiveScriptSegments = effectiveScriptSegments.slice(0, imagePaths.length); | |
| } | |
| const audioFilePaths = []; | |
| const videoClipCommands = []; | |
| const audioConcatenationCommands = []; | |
| console.log("Generando audio con Text-to-Speech y preparando clips..."); | |
| for (let i = 0; i < imagePaths.length; i++) { | |
| const segmentText = effectiveScriptSegments[i]; | |
| const imagePath = imagePaths[i]; | |
| const audioFilePath = await textToSpeech(segmentText, voiceLanguage, service); | |
| let audioDuration = 0; | |
| if (audioFilePath) { | |
| audioDuration = await getAudioDuration(audioFilePath); | |
| } | |
| if (audioDuration < 1.0) { | |
| audioDuration = parseFloat(imageDuration); | |
| } | |
| if (audioFilePath) { | |
| audioFilePaths.push(audioFilePath); | |
| audioConcatenationCommands.push(`-i "${audioFilePath}"`); | |
| } else { | |
| console.warn(`No se pudo generar audio para el segmento ${i + 1}. Se usará un silencio de ${audioDuration}s.`); | |
| const silencePath = path.join(TEMP_DIR, `silence_${audioDuration}s_${uuid.v4()}.mp3`); | |
| await new Promise((resolve, reject) => { | |
| exec(`ffmpeg -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -t ${audioDuration} -q:a 9 -acodec libmp3lame "${silencePath}"`, (error) => { | |
| if (error) { | |
| console.error(`Error generando silencio: ${error}`); | |
| return reject(error); | |
| } | |
| resolve(); | |
| }); | |
| }); | |
| audioFilePaths.push(silencePath); | |
| audioConcatenationCommands.push(`-i "${silencePath}"`); | |
| } | |
| videoClipCommands.push(`-loop 1 -t ${audioDuration} -i "${imagePath}"`); | |
| } | |
| if (videoClipCommands.length === 0 || audioConcatenationCommands.length === 0) { | |
| return res.status(500).json({ error: 'No se pudieron crear clips de video o audio válidos para la composición.' }); | |
| } | |
| console.log("Componiendo video final..."); | |
| const outputVideoPath = path.join(TEMP_DIR, `noticia_ia_${uuid.v4()}.mp4`); | |
| const complexFilter = []; | |
| let inputCount = 0; | |
| for (let i = 0; i < imagePaths.length; i++) { | |
| complexFilter.push(`[${inputCount++}:v]scale=w=1280:h=720:force_original_aspect_ratio=increase,crop=w=1280:h=720,setsar=1[v${i}];`); | |
| } | |
| let concatVideoFilter = ''; | |
| for (let i = 0; i < imagePaths.length; i++) { | |
| concatVideoFilter += `[v${i}]`; | |
| } | |
| concatVideoFilter += `concat=n=${imagePaths.length}:v=1:a=0[outv]`; | |
| complexFilter.push(concatVideoFilter); | |
| let concatAudioFilter = ''; | |
| for (let i = 0; i < audioFilePaths.length; i++) { | |
| concatAudioFilter += `[${inputCount++}:a]`; | |
| } | |
| concatAudioFilter += `concat=n=${audioFilePaths.length}:v=0:a=1[outa]`; | |
| complexFilter.push(concatAudioFilter); | |
| const ffmpegArgs = [ | |
| ...videoClipCommands, | |
| ...audioConcatenationCommands, | |
| '-filter_complex', complexFilter.join(''), | |
| '-map', '[outv]', | |
| '-map', '[outa]', | |
| '-c:v', 'libx264', | |
| '-profile:v', 'main', | |
| '-level', '3.1', | |
| '-pix_fmt', 'yuv420p', | |
| '-r', '24', | |
| '-preset', 'medium', | |
| '-crf', '23', | |
| '-c:a', 'aac', | |
| '-b:a', '192k', | |
| '-movflags', '+faststart', | |
| outputVideoPath | |
| ]; | |
| await new Promise((resolve, reject) => { | |
| const ffmpegProcess = exec(`ffmpeg ${ffmpegArgs.join(' ')}`); | |
| ffmpegProcess.stderr.on('data', (data) => { | |
| console.error(`FFmpeg stderr: ${data}`); | |
| }); | |
| ffmpegProcess.on('close', (code) => { | |
| if (code !== 0) { | |
| return reject(new Error(`FFmpeg exited with code ${code}`)); | |
| } | |
| resolve(); | |
| }); | |
| }); | |
| res.download(outputVideoPath, path.basename(outputVideoPath), async (err) => { | |
| if (err) { | |
| console.error('Error enviando video:', err); | |
| res.status(500).json({ error: 'Fallo al enviar el video generado.' }); | |
| } | |
| try { | |
| const allTempFiles = [...imagePaths, ...audioFilePaths, outputVideoPath, ...uploadedFiles.map(f => f.path)]; | |
| for (const file of allTempFiles) { | |
| if (file && (await fs.access(file).then(() => true).catch(() => false))) { | |
| await fs.unlink(file); | |
| } | |
| } | |
| console.log("Archivos temporales limpiados."); | |
| } catch (cleanupErr) { | |
| console.error('Error al limpiar archivos temporales:', cleanupErr); | |
| } | |
| }); | |
| } catch (error) { | |
| console.error('Error fatal en /generate-video:', error); | |
| res.status(500).json({ error: error.message }); | |
| try { | |
| if (req.files) { | |
| for (const file of req.files) { | |
| await fs.unlink(file.path); | |
| } | |
| } | |
| } catch (cleanupErr) { | |
| console.error('Error al limpiar archivos de carga inicial:', cleanupErr); | |
| } | |
| } | |
| }); | |
| const PORT = process.env.PORT || 5000; | |
| app.listen(PORT, async () => { | |
| await ensureDir(TEMP_DIR); | |
| console.log(`Servidor Node.js escuchando en el puerto ${PORT}`); | |
| }); | |