Spaces:
Runtime error
Runtime error
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()); | |
// Se cambia el destino de Multer a un directorio dentro de /tmp, que es siempre escribible en Docker | |
const upload = multer({ dest: '/tmp/temp_uploads/' }); | |
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 { | |
ttsClient = new TextToSpeechClient(); | |
} | |
// Se cambia el directorio temporal general a un directorio dentro de /tmp | |
const TEMP_DIR = '/tmp/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}`; | |
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); | |
// Es importante eliminar el archivo original subido por multer después de procesarlo | |
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); // Asegura que TEMP_DIR exista | |
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) => { | |
// Asegura que tanto TEMP_DIR como el directorio de destino de Multer existan y sean escribibles | |
await ensureDir(TEMP_DIR); | |
await ensureDir(upload.dest); // Crea el directorio para los uploads de Multer si no existe | |
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 () => { | |
// Asegura que los directorios temporales existan al iniciar el servidor | |
await ensureDir(TEMP_DIR); | |
await ensureDir('/tmp/temp_uploads'); // Asegura que el directorio de uploads de Multer exista | |
console.log(`Servidor Node.js escuchando en el puerto ${PORT}`); | |
}); | |