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}`); });