news2 / server.js
salomonsky's picture
Update server.js
28a44c3 verified
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}`);
});