salomonsky commited on
Commit
8e366b1
verified
1 Parent(s): 36d42cb

Create server.js

Browse files
Files changed (1) hide show
  1. server.js +425 -0
server.js ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const cors = require('cors');
3
+ const multer = require('multer');
4
+ const { promises: fs } = require('fs');
5
+ const path = require('path');
6
+ const { exec } = require('child_process');
7
+ const { TextToSpeechClient } = require('@google-cloud/text-to-speech');
8
+ const sharp = require('sharp');
9
+ const axios = require('axios');
10
+ const uuid = require('uuid');
11
+
12
+ const app = express();
13
+ app.use(cors());
14
+ app.use(express.json());
15
+
16
+ const upload = multer({ dest: 'temp_uploads/' });
17
+
18
+ // Usar solo GEMINI_API_KEY para todo, y buscar 'gemini_api' en min煤sculas tambi茅n.
19
+ const GEMINI_API_KEY = process.env.GEMINI_API_KEY || process.env.gemini_api || '';
20
+
21
+ const TTS_CREDENTIALS = process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON ? JSON.parse(process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON) : null;
22
+ let ttsClient;
23
+ if (TTS_CREDENTIALS) {
24
+ ttsClient = new TextToSpeechClient({ credentials: TTS_CREDENTIALS });
25
+ } else {
26
+ // Si no hay credenciales JSON expl铆citas, intentar谩 usar GOOGLE_APPLICATION_CREDENTIALS
27
+ // o el entorno de ejecuci贸n predeterminado de Google Cloud.
28
+ ttsClient = new TextToSpeechClient();
29
+ }
30
+
31
+ const TEMP_DIR = path.join(__dirname, 'temp_files');
32
+
33
+ async function ensureDir(dir) {
34
+ try {
35
+ await fs.mkdir(dir, { recursive: true });
36
+ } catch (error) {
37
+ if (error.code !== 'EEXIST') {
38
+ throw error;
39
+ }
40
+ }
41
+ }
42
+
43
+ async function enhanceTextWithGemini(textInput) {
44
+ try {
45
+ const chatHistory = [{
46
+ role: "user",
47
+ parts: [{ text: `Mejora y profesionaliza el siguiente texto para un noticiero, hazlo conciso y atractivo, sin a帽adir introducciones ni despedidas: '${textInput}'` }]
48
+ }];
49
+ const payload = { contents: chatHistory };
50
+
51
+ const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}`;
52
+ const response = await axios.post(apiUrl, payload);
53
+
54
+ if (response.data.candidates && response.data.candidates.length > 0 &&
55
+ response.data.candidates[0].content && response.data.candidates[0].content.parts &&
56
+ response.data.candidates[0].content.parts.length > 0) {
57
+ return response.data.candidates[0].content.parts[0].text;
58
+ } else {
59
+ console.warn('Respuesta inesperada de Gemini para mejora de texto.');
60
+ return textInput;
61
+ }
62
+ } catch (error) {
63
+ console.error('Error en la llamada a la API de Gemini para texto:', error.message);
64
+ return textInput;
65
+ }
66
+ }
67
+
68
+ async function generateImageWithGemini(prompt, aspectRatio) {
69
+ try {
70
+ const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/imagen-3.0-generate-002:predict?key=${GEMINI_API_KEY}`; // Usando GEMINI_API_KEY aqu铆
71
+ const payload = {
72
+ instances: { prompt: prompt },
73
+ parameters: { sampleCount: 1 }
74
+ };
75
+
76
+ const response = await axios.post(apiUrl, payload);
77
+
78
+ if (response.data.predictions && response.data.predictions.length > 0 && response.data.predictions[0].bytesBase64Encoded) {
79
+ const base64Image = response.data.predictions[0].bytesBase64Encoded;
80
+ const imgBuffer = Buffer.from(base64Image, 'base64');
81
+
82
+ const outputFilename = path.join(TEMP_DIR, `generated_image_${uuid.v4()}.png`);
83
+
84
+ let targetWidth, targetHeight;
85
+ if (aspectRatio === "16:9") {
86
+ targetWidth = 1280;
87
+ targetHeight = 720;
88
+ } else {
89
+ targetWidth = 720;
90
+ targetHeight = 1280;
91
+ }
92
+
93
+ await sharp(imgBuffer)
94
+ .resize(targetWidth, targetHeight, {
95
+ fit: sharp.fit.cover,
96
+ position: sharp.strategy.attention
97
+ })
98
+ .toFile(outputFilename);
99
+
100
+ return outputFilename;
101
+ } else {
102
+ throw new Error('Respuesta inesperada de la API de Imagen: no se encontr贸 base64_image.');
103
+ }
104
+ } catch (error) {
105
+ console.error('Error al generar imagen con Gemini:', error.message);
106
+ return null;
107
+ }
108
+ }
109
+
110
+ async function processUploadedImage(filePath, aspectRatio) {
111
+ try {
112
+ let targetWidth, targetHeight;
113
+ if (aspectRatio === "16:9") {
114
+ targetWidth = 1280;
115
+ targetHeight = 720;
116
+ } else {
117
+ targetWidth = 720;
118
+ targetHeight = 1280;
119
+ }
120
+
121
+ const outputFilename = path.join(TEMP_DIR, `processed_uploaded_image_${uuid.v4()}${path.extname(filePath)}`);
122
+
123
+ await sharp(filePath)
124
+ .resize(targetWidth, targetHeight, {
125
+ fit: sharp.fit.cover,
126
+ position: sharp.strategy.attention
127
+ })
128
+ .toFile(outputFilename);
129
+
130
+ await fs.unlink(filePath);
131
+ return outputFilename;
132
+ } catch (error) {
133
+ console.error('Error al procesar imagen subida:', error.message);
134
+ return null;
135
+ }
136
+ }
137
+
138
+ async function textToSpeech(text, langCode, service) {
139
+ const outputFilePath = path.join(TEMP_DIR, `audio_${uuid.v4()}.mp3`);
140
+
141
+ try {
142
+ if (service === 'gtts') {
143
+ const [languageCode, regionCode] = langCode.split('-');
144
+ const request = {
145
+ input: { text: text },
146
+ voice: { languageCode: langCode, name: `${languageCode}-Standard-A` },
147
+ audioConfig: { audioEncoding: 'MP3' },
148
+ };
149
+
150
+ const [response] = await ttsClient.synthesizeSpeech(request);
151
+ await fs.writeFile(outputFilePath, response.audioContent, 'binary');
152
+ return outputFilePath;
153
+
154
+ } else if (service === 'edge') {
155
+ console.warn('Servicio Edge TTS no implementado. Usando TTS de Google Cloud como fallback.');
156
+ const [languageCode, regionCode] = langCode.split('-');
157
+ const request = {
158
+ input: { text: text },
159
+ voice: { languageCode: langCode, name: `${languageCode}-Standard-A` },
160
+ audioConfig: { audioEncoding: 'MP3' },
161
+ };
162
+ const [response] = await ttsClient.synthesizeSpeech(request);
163
+ await fs.writeFile(outputFilePath, response.audioContent, 'binary');
164
+ return outputFilePath;
165
+ } else {
166
+ throw new Error('Servicio de voz no soportado.');
167
+ }
168
+ } catch (error) {
169
+ console.error(`Error al generar TTS para "${text.substring(0, 50)}...":`, error.message);
170
+ return null;
171
+ }
172
+ }
173
+
174
+ async function getAudioDuration(audioPath) {
175
+ return new Promise((resolve, reject) => {
176
+ exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${audioPath}"`, (error, stdout, stderr) => {
177
+ if (error) {
178
+ console.error(`Error al obtener duraci贸n del audio: ${stderr}`);
179
+ return resolve(0);
180
+ }
181
+ resolve(parseFloat(stdout));
182
+ });
183
+ });
184
+ }
185
+
186
+ app.post('/test-voice', async (req, res) => {
187
+ await ensureDir(TEMP_DIR);
188
+ const { text, lang, service } = req.body;
189
+ try {
190
+ const audioFilePath = await textToSpeech(text, lang, service);
191
+ if (audioFilePath && await fs.access(audioFilePath).then(() => true).catch(() => false)) {
192
+ res.download(audioFilePath, path.basename(audioFilePath), async (err) => {
193
+ if (err) {
194
+ console.error('Error enviando archivo de audio:', err);
195
+ res.status(500).json({ error: 'Fallo al enviar archivo de audio de prueba.' });
196
+ }
197
+ try {
198
+ await fs.unlink(audioFilePath);
199
+ } catch (cleanupErr) {
200
+ console.error('Error al limpiar archivo de audio de prueba:', cleanupErr);
201
+ }
202
+ });
203
+ } else {
204
+ res.status(500).json({ error: 'Fallo al generar voz de prueba.' });
205
+ }
206
+ } catch (error) {
207
+ console.error('Error en /test-voice:', error);
208
+ res.status(500).json({ error: error.message });
209
+ }
210
+ });
211
+
212
+ app.post('/generate-video', upload.array('photos'), async (req, res) => {
213
+ await ensureDir(TEMP_DIR);
214
+ const { script, useLlmForScript, aspectRatio, imageDuration, shouldGenerateImages, imageGenerationPrompt, voiceService, voiceLanguage } = req.body;
215
+ let uploadedFiles = req.files || [];
216
+
217
+ try {
218
+ if (uploadedFiles.length === 0 && shouldGenerateImages !== 'true') {
219
+ 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.' });
220
+ }
221
+ if (uploadedFiles.length > 0 && shouldGenerateImages === 'true') {
222
+ 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.' });
223
+ }
224
+ if (shouldGenerateImages === 'true' && !imageGenerationPrompt) {
225
+ return res.status(400).json({ error: 'Para generar im谩genes con IA, debes proporcionar un prompt descriptivo.' });
226
+ }
227
+ if (!script && useLlmForScript !== 'true') {
228
+ 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".' });
229
+ }
230
+
231
+ let finalScript = script;
232
+ if (useLlmForScript === 'true' && script) {
233
+ console.log("Mejorando guion con Gemini Flash...");
234
+ finalScript = await enhanceTextWithGemini(script);
235
+ console.log("Guion mejorado:", finalScript);
236
+ } else if (!finalScript) {
237
+ console.warn("No se pudo obtener un guion final. Usando un placeholder.");
238
+ finalScript = "Noticia importante: Hoy exploramos un tema fascinante. Mant茅ngase al tanto para m谩s actualizaciones.";
239
+ }
240
+
241
+ let scriptSegments = finalScript.split('\n').map(s => s.trim()).filter(s => s);
242
+ if (scriptSegments.length === 0) {
243
+ scriptSegments = ["Una noticia sin descripci贸n. M谩s informaci贸n a continuaci贸n."];
244
+ }
245
+
246
+ const imagePaths = [];
247
+ if (uploadedFiles.length > 0) {
248
+ console.log(`Procesando ${uploadedFiles.length} im谩genes subidas...`);
249
+ if (uploadedFiles.length > 10) {
250
+ return res.status(400).json({ error: "Solo se permite un m谩ximo de 10 fotos." });
251
+ }
252
+ for (const file of uploadedFiles) {
253
+ const processedPath = await processUploadedImage(file.path, aspectRatio);
254
+ if (processedPath) {
255
+ imagePaths.push(processedPath);
256
+ } else {
257
+ console.error(`Error al procesar imagen ${file.originalname}.`);
258
+ }
259
+ }
260
+ } else if (shouldGenerateImages === 'true') {
261
+ console.log("Generando im谩genes con IA (Imagen 3.0)...");
262
+ const numImagesToGenerate = Math.min(scriptSegments.length > 0 ? scriptSegments.length : 1, 10);
263
+ for (let i = 0; i < numImagesToGenerate; i++) {
264
+ const currentPrompt = numImagesToGenerate > 1 ? `${imageGenerationPrompt} - Parte ${i + 1} de la noticia.` : imageGenerationPrompt;
265
+ const generatedImgPath = await generateImageWithGemini(currentPrompt, aspectRatio);
266
+ if (generatedImgPath) {
267
+ imagePaths.push(generatedImgPath);
268
+ } else {
269
+ console.error(`Error al generar la imagen ${i + 1}.`);
270
+ }
271
+ }
272
+ }
273
+
274
+ if (imagePaths.length === 0) {
275
+ return res.status(500).json({ error: 'No se pudieron obtener im谩genes v谩lidas para el video (ni subidas ni generadas).' });
276
+ }
277
+
278
+ let effectiveScriptSegments = [...scriptSegments];
279
+ if (effectiveScriptSegments.length < imagePaths.length) {
280
+ while (effectiveScriptSegments.length < imagePaths.length) {
281
+ effectiveScriptSegments.push("M谩s informaci贸n sobre esta imagen.");
282
+ }
283
+ } else if (effectiveScriptSegments.length > imagePaths.length) {
284
+ effectiveScriptSegments = effectiveScriptSegments.slice(0, imagePaths.length);
285
+ }
286
+
287
+ const audioFilePaths = [];
288
+ const videoClipCommands = [];
289
+ const audioConcatenationCommands = [];
290
+
291
+ console.log("Generando audio con Text-to-Speech y preparando clips...");
292
+
293
+ for (let i = 0; i < imagePaths.length; i++) {
294
+ const segmentText = effectiveScriptSegments[i];
295
+ const imagePath = imagePaths[i];
296
+ const audioFilePath = await textToSpeech(segmentText, voiceLanguage, service);
297
+
298
+ let audioDuration = 0;
299
+ if (audioFilePath) {
300
+ audioDuration = await getAudioDuration(audioFilePath);
301
+ }
302
+ if (audioDuration < 1.0) {
303
+ audioDuration = parseFloat(imageDuration);
304
+ }
305
+
306
+ if (audioFilePath) {
307
+ audioFilePaths.push(audioFilePath);
308
+ audioConcatenationCommands.push(`-i "${audioFilePath}"`);
309
+ } else {
310
+ console.warn(`No se pudo generar audio para el segmento ${i + 1}. Se usar谩 un silencio de ${audioDuration}s.`);
311
+ const silencePath = path.join(TEMP_DIR, `silence_${audioDuration}s_${uuid.v4()}.mp3`);
312
+ await new Promise((resolve, reject) => {
313
+ exec(`ffmpeg -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -t ${audioDuration} -q:a 9 -acodec libmp3lame "${silencePath}"`, (error) => {
314
+ if (error) {
315
+ console.error(`Error generando silencio: ${error}`);
316
+ return reject(error);
317
+ }
318
+ resolve();
319
+ });
320
+ });
321
+ audioFilePaths.push(silencePath);
322
+ audioConcatenationCommands.push(`-i "${silencePath}"`);
323
+ }
324
+
325
+ videoClipCommands.push(`-loop 1 -t ${audioDuration} -i "${imagePath}"`);
326
+ }
327
+
328
+ if (videoClipCommands.length === 0 || audioConcatenationCommands.length === 0) {
329
+ return res.status(500).json({ error: 'No se pudieron crear clips de video o audio v谩lidos para la composici贸n.' });
330
+ }
331
+
332
+ console.log("Componiendo video final...");
333
+
334
+ const outputVideoPath = path.join(TEMP_DIR, `noticia_ia_${uuid.v4()}.mp4`);
335
+ const complexFilter = [];
336
+ let inputCount = 0;
337
+
338
+ for (let i = 0; i < imagePaths.length; i++) {
339
+ complexFilter.push(`[${inputCount++}:v]scale=w=1280:h=720:force_original_aspect_ratio=increase,crop=w=1280:h=720,setsar=1[v${i}];`);
340
+ }
341
+
342
+ let concatVideoFilter = '';
343
+ for (let i = 0; i < imagePaths.length; i++) {
344
+ concatVideoFilter += `[v${i}]`;
345
+ }
346
+ concatVideoFilter += `concat=n=${imagePaths.length}:v=1:a=0[outv]`;
347
+ complexFilter.push(concatVideoFilter);
348
+
349
+ let concatAudioFilter = '';
350
+ for (let i = 0; i < audioFilePaths.length; i++) {
351
+ concatAudioFilter += `[${inputCount++}:a]`;
352
+ }
353
+ concatAudioFilter += `concat=n=${audioFilePaths.length}:v=0:a=1[outa]`;
354
+ complexFilter.push(concatAudioFilter);
355
+
356
+ const ffmpegArgs = [
357
+ ...videoClipCommands,
358
+ ...audioConcatenationCommands,
359
+ '-filter_complex', complexFilter.join(''),
360
+ '-map', '[outv]',
361
+ '-map', '[outa]',
362
+ '-c:v', 'libx264',
363
+ '-profile:v', 'main',
364
+ '-level', '3.1',
365
+ '-pix_fmt', 'yuv420p',
366
+ '-r', '24',
367
+ '-preset', 'medium',
368
+ '-crf', '23',
369
+ '-c:a', 'aac',
370
+ '-b:a', '192k',
371
+ '-movflags', '+faststart',
372
+ outputVideoPath
373
+ ];
374
+
375
+ await new Promise((resolve, reject) => {
376
+ const ffmpegProcess = exec(`ffmpeg ${ffmpegArgs.join(' ')}`);
377
+ ffmpegProcess.stderr.on('data', (data) => {
378
+ console.error(`FFmpeg stderr: ${data}`);
379
+ });
380
+ ffmpegProcess.on('close', (code) => {
381
+ if (code !== 0) {
382
+ return reject(new Error(`FFmpeg exited with code ${code}`));
383
+ }
384
+ resolve();
385
+ });
386
+ });
387
+
388
+ res.download(outputVideoPath, path.basename(outputVideoPath), async (err) => {
389
+ if (err) {
390
+ console.error('Error enviando video:', err);
391
+ res.status(500).json({ error: 'Fallo al enviar el video generado.' });
392
+ }
393
+ try {
394
+ const allTempFiles = [...imagePaths, ...audioFilePaths, outputVideoPath, ...uploadedFiles.map(f => f.path)];
395
+ for (const file of allTempFiles) {
396
+ if (file && (await fs.access(file).then(() => true).catch(() => false))) {
397
+ await fs.unlink(file);
398
+ }
399
+ }
400
+ console.log("Archivos temporales limpiados.");
401
+ } catch (cleanupErr) {
402
+ console.error('Error al limpiar archivos temporales:', cleanupErr);
403
+ }
404
+ });
405
+
406
+ } catch (error) {
407
+ console.error('Error fatal en /generate-video:', error);
408
+ res.status(500).json({ error: error.message });
409
+ try {
410
+ if (req.files) {
411
+ for (const file of req.files) {
412
+ await fs.unlink(file.path);
413
+ }
414
+ }
415
+ } catch (cleanupErr) {
416
+ console.error('Error al limpiar archivos de carga inicial:', cleanupErr);
417
+ }
418
+ }
419
+ });
420
+
421
+ const PORT = process.env.PORT || 5000;
422
+ app.listen(PORT, async () => {
423
+ await ensureDir(TEMP_DIR);
424
+ console.log(`Servidor Node.js escuchando en el puerto ${PORT}`);
425
+ });