news2 / index.html
salomonsky's picture
Update index.html
b656836 verified
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Creador de Noticias en Video con IA</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Estilos generales del cuerpo */
body {
font-family: 'Inter', sans-serif;
background: linear-gradient(to bottom right, #f0f2f5, #e0e7ee);
color: #333;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
}
/* Estilos del contenedor principal de la aplicación */
.container {
background-color: #ffffff;
padding: 30px;
border-radius: 20px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15);
max-width: 900px;
width: 100%;
margin-top: 20px;
margin-bottom: 20px;
}
/* Estilos del título de sección */
.section-title {
font-size: 1.8rem;
font-weight: bold;
color: #2c3e50;
margin-bottom: 20px;
border-bottom: 2px solid #d1d5db;
padding-bottom: 10px;
}
/* Estilos de las etiquetas de los grupos de entrada */
.input-group label {
font-weight: 600;
margin-bottom: 8px;
display: block;
color: #555;
}
/* Estilos del botón principal */
.btn-primary {
background-color: #4285f4;
color: white;
padding: 12px 25px;
border-radius: 12px;
font-weight: 700;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
cursor: pointer;
border: none;
box-shadow: 0 4px 10px rgba(66, 133, 244, 0.3);
}
/* Estilos del botón principal al pasar el ratón por encima */
.btn-primary:hover {
background-color: #357ae8;
box-shadow: 0 6px 15px rgba(66, 133, 244, 0.4);
}
/* Estilos del contenedor de miniaturas (para arrastrar y soltar imágenes) */
.thumbnail-container {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 15px;
justify-content: center;
border: 2px dashed #d1d5db;
border-radius: 8px;
padding: 15px;
min-height: 100px;
align-items: center;
font-size: 0.9rem;
color: #666;
text-align: center;
}
/* Estilos del contenedor de miniaturas cuando tiene archivos */
.thumbnail-container.has-files {
border: 2px solid #4285f4;
}
/* Estilos del contenedor de miniaturas al arrastrar un archivo sobre él */
.thumbnail-container.drag-over {
background-color: #e0f2fe;
border-color: #2196f3;
}
/* Estilos de las miniaturas de imagen */
.thumbnail {
width: 120px;
height: 90px;
object-fit: cover;
border-radius: 8px;
border: 2px solid #e0e0e0;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
}
/* Estilos de los campos de entrada de texto, selectores y áreas de texto */
textarea, select, input[type="file"], input[type="text"], input[type="number"] {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px;
width: 100%;
box-sizing: border-box;
background-color: #f9fafb;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
/* Estilos de los campos de entrada al enfocar */
textarea:focus, select:focus, input[type="file"]:focus, input[type="text"]:focus, input[type="number"]:focus {
border-color: #4285f4;
box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.25);
outline: none;
}
/* Estilos del spinner de carga */
.loading-spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #4285f4;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 20px auto;
display: none;
}
/* Animación del spinner */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Estilos del marcador de posición del video */
.video-placeholder {
background-color: #e0e0e0;
height: 400px;
width: 100%;
border-radius: 12px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #666;
font-size: 1.2rem;
text-align: center;
margin-top: 20px;
overflow: hidden;
}
/* Estilos del elemento de video dentro del marcador de posición */
.video-placeholder video {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 12px;
}
/* Estilos del contenedor de video con relación de aspecto */
.video-wrapper {
position: relative;
padding-bottom: 56.25%; /* 16:9 Aspect Ratio */
height: 0;
overflow: hidden;
border-radius: 12px;
margin-top: 20px;
background-color: #e0e0e0;
}
/* Ajuste para relación de aspecto 9:16 */
.video-wrapper.aspect-9-16 {
padding-bottom: 177.77%; /* 9:16 Aspect Ratio (16/9 * 100%) */
}
/* Estilos del elemento de video dentro del contenedor de video */
.video-wrapper video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 12px;
}
/* Estilos de la caja de mensajes */
.message-box {
padding: 15px;
border-radius: 8px;
margin-top: 15px;
display: none;
font-size: 0.95rem;
font-weight: 500;
}
/* Estilos para mostrar la caja de mensajes */
.message-box.show {
display: block;
}
/* Colores de los mensajes de información */
.info-message {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
/* Colores de los mensajes de error */
.error-message {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
/* Colores de los mensajes de éxito */
.success-message {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
/* Colores de los mensajes de advertencia */
.warning-message {
background-color: #ffeeba;
color: #856404;
border: 1px solid #ffcd00;
}
/* Estilos del overlay del modal personalizado */
.custom-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
/* Estilos para mostrar el overlay del modal */
.custom-modal-overlay.show {
opacity: 1;
visibility: visible;
}
/* Estilos del contenido del modal personalizado */
.custom-modal-content {
background-color: #fff;
padding: 30px;
border-radius: 15px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
text-align: center;
max-width: 450px;
width: 90%;
transform: translateY(-20px);
transition: transform 0.3s ease;
}
/* Estilos para mostrar el contenido del modal */
.custom-modal-overlay.show .custom-modal-content {
transform: translateY(0);
}
/* Estilos del título del modal */
.custom-modal-content h3 {
font-size: 1.5rem;
font-weight: bold;
color: #2c3e50;
margin-bottom: 15px;
}
/* Estilos del párrafo del modal */
.custom-modal-content p {
font-size: 1rem;
color: #555;
margin-bottom: 25px;
}
/* Estilos del botón del modal */
.custom-modal-content button {
background-color: #4285f4;
color: white;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
border: none;
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 5px rgba(66, 133, 244, 0.2);
}
/* Estilos del botón del modal al pasar el ratón por encima */
.custom-modal-content button:hover {
background-color: #357ae8;
box-shadow: 0 4px 8px rgba(66, 133, 244, 0.3);
}
/* Estilos para la selección de la relación de aspecto */
.aspect-ratio-option {
display: flex;
align-items: center;
padding: 8px 12px;
border: 2px solid #ccc;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
/* Estilos de la opción de relación de aspecto seleccionada */
.aspect-ratio-option.selected {
border-color: #4285f4;
background-color: #e3f2fd;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.3);
}
/* Estilos del input de radio dentro de la opción de relación de aspecto */
.aspect-ratio-option input[type="radio"] {
margin-right: 8px;
}
</style>
</head>
<body>
<div class="container">
<h1 class="section-title">✨ Creador de Noticias en Video con IA ✨</h1>
<!-- Sección 0: Configuración de API y Opciones Avanzadas -->
<div class="mb-8 p-4 bg-gray-50 rounded-xl shadow-inner">
<h2 class="text-2xl font-semibold mb-4 text-gray-700">0. Configuración de API y Opciones Avanzadas</h2>
<div class="input-group mb-4">
<label for="llmService" class="block text-gray-700 text-sm font-bold mb-2">Servicio de LLM para Guion:</label>
<select id="llmService" class="rounded-lg">
<option value="gemini">Google Gemini</option>
<option value="gpt">OpenAI GPT</option>
<option value="anthropic">Anthropic Claude</option>
<option value="bing">Microsoft Bing (Copilot)</option>
<option value="custom">Mi LLM Personalizado</option>
<option value="none">No usar LLM (solo mi guion)</option>
</select>
</div>
<div id="customLlmEndpointGroup" class="input-group mb-4 hidden">
<label for="customLlmEndpoint" class="block text-gray-700 text-sm font-bold mb-2">URL del Endpoint de mi LLM Personalizado:</label>
<input type="text" id="customLlmEndpoint" placeholder="Ej: https://mi-modelo-personalizado.huggingface.app/generate" class="rounded-lg">
<p class="text-sm text-gray-500 mt-1">Tu backend será el encargado de comunicarse con esta URL.</p>
</div>
<div class="input-group mb-4">
<label for="genericApiKey" class="block text-gray-700 text-sm font-bold mb-2">Clave de API (Para pruebas locales):</label>
<input type="text" id="genericApiKey" placeholder="Ingresa tu clave de API aquí" class="rounded-lg">
<div id="apiKeyWarning" class="message-box warning-message mt-2 show">
<strong>Advertencia de Seguridad:</strong> Para despliegues (ej. Hugging Face), tu clave de API debe cargarse de forma segura desde variables de entorno/secretos del servidor (ej. `gemini_api`). Si no se introduce aquí, el backend intentará usar las variables de entorno de tu Space.
</div>
</div>
<div class="input-group mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2">Relación de Aspecto del Video:</label>
<div class="flex items-center space-x-4">
<label for="aspectRatio16_9" class="aspect-ratio-option selected">
<input type="radio" id="aspectRatio16_9" name="aspectRatio" value="16:9" checked class="h-4 w-4 text-blue-600 border-gray-300 rounded-full focus:ring-blue-500">
<span class="text-gray-700">16:9 (Horizontal)</span>
</label>
<label for="aspectRatio9_16" class="aspect-ratio-option">
<input type="radio" id="aspectRatio9_16" name="aspectRatio" value="9:16" class="h-4 w-4 text-blue-600 border-gray-300 rounded-full focus:ring-blue-500">
<span class="text-gray-700">9:16 (Vertical)</span>
</label>
</div>
<p class="text-sm text-gray-500 mt-1">Las imágenes se adaptarán profesionalmente al formato seleccionado (redimensionamiento y/o recorte inteligente).</p>
</div>
<div class="input-group mb-4">
<label for="imageDuration" class="block text-gray-700 text-sm font-bold mb-2">Duración de cada imagen (segundos):</label>
<input type="number" id="imageDuration" value="3" min="1" step="0.5" class="py-2 px-3 rounded-lg">
<div id="imageDurationMessage" class="message-box mt-2"></div>
<p class="text-sm text-gray-500 mt-1">Define cuánto tiempo cada imagen aparecerá en el video. Si el guion tiene audio, la duración de la imagen se ajustará a la duración del audio para ese segmento.</p>
</div>
<div class="input-group mb-4">
<label for="voiceService" class="block text-gray-700 text-sm font-bold mb-2">Servicio de Voz:</label>
<select id="voiceService" class="rounded-lg">
<option value="gtts" selected>Google Text-to-Speech (GTTS) - Por defecto</option>
<option value="edge">Microsoft Edge TTS (Opción alternativa)</option>
</select>
<p class="text-sm text-gray-500 mt-1">GTTS usa Google Cloud Text-to-Speech. La opción Edge es un placeholder y no funciona actualmente.</p>
</div>
</div>
<!-- Sección 1: Generación de Imágenes (si no se suben fotos) -->
<div class="mb-8 p-4 bg-gray-50 rounded-xl shadow-inner">
<h2 class="text-2xl font-semibold mb-4 text-gray-700">1. Generación de Imágenes (si no subes fotos)</h2>
<div class="flex items-center mb-4">
<input type="checkbox" id="generateImagesWithAI" class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 mr-2">
<label for="generateImagesWithAI" class="text-gray-700 text-sm">Generar imágenes con IA si no subo fotos</label>
</div>
<div id="imageGenerationOptions" class="hidden">
<div class="input-group mb-4">
<label for="imageGenerationService" class="block text-gray-700 text-sm font-bold mb-2">Servicio de Generación de Imágenes:</label>
<select id="imageGenerationService" class="rounded-lg">
<option value="google_imagen" selected>Google Imagen (Imagen 3.0)</option>
<option value="dalle">OpenAI DALL-E (No implementado en backend)</option>
<option value="custom_image_gen">Mi Servicio Personalizado (No implementado en backend)</option>
</select>
</div>
<div id="customImageGenEndpointGroup" class="input-group mb-4 hidden">
<label for="customImageGenEndpoint" class="block text-gray-700 text-sm font-bold mb-2">URL del Endpoint de mi Servicio Personalizado:</label>
<input type="text" id="customImageGenEndpoint" placeholder="Ej: https://mi-generador-ia.huggingface.app/generate" class="rounded-lg">
</div>
<div class="input-group mb-4">
<label for="imageGenerationPrompt" class="block text-gray-700 text-sm font-bold mb-2">Prompt para generar imágenes (ej. "paisajes de El Principito, estilo acuarela"):</label>
<textarea id="imageGenerationPrompt" rows="3" placeholder="Describe las imágenes que quieres generar..." class="rounded-lg"></textarea>
<p class="text-sm text-gray-500 mt-1">Se generarán entre 1 y 10 imágenes (según tu guion) basadas en este prompt. Est. 1.5 minutos por imagen.</p>
</div>
</div>
<div id="imageGenMessage" class="message-box mt-4 rounded-lg"></div>
</div>
<!-- Sección 2: Carga de Fotos -->
<div class="mb-8 p-4 bg-gray-50 rounded-xl shadow-inner">
<h2 class="text-2xl font-semibold mb-4 text-gray-700">2. Sube tus Fotos (Máx. 10)</h2>
<div class="input-group mb-4">
<label for="photoUpload" class="block text-gray-700 text-sm font-bold mb-2">Selecciona imágenes (JPG, PNG, GIF) o arrastra y suelta:</label>
<input type="file" id="photoUpload" multiple accept="image/*" class="py-2 px-3 rounded-lg">
<p id="photoCount" class="text-sm text-gray-500 mt-2">0/10 fotos seleccionadas</p>
</div>
<div id="thumbnails" class="thumbnail-container">
Arrastra y suelta tus imágenes aquí o usa el botón de "Seleccionar imágenes".
</div>
<div id="imageErrorMessage" class="message-box error-message mt-4 rounded-lg"></div>
</div>
<!-- Sección 3: Escritura del Guion -->
<div class="mb-8 p-4 bg-gray-50 rounded-xl shadow-inner">
<h2 class="text-2xl font-semibold mb-4 text-gray-700">3. Escribe el Guion de tu Noticia</h2>
<div class="input-group mb-4">
<label for="scriptTextarea" class="block text-gray-700 text-sm font-bold mb-2">Tu guion aquí (un párrafo por cada foto):</label>
<textarea id="scriptTextarea" rows="8" placeholder="Ej: Hola, hoy exploraremos el fascinante mundo de El Principito. En nuestra primera imagen, vemos al Principito en su pequeño asteroide, cuidando su rosa. Esta escena evoca la ternura y la responsabilidad..." class="rounded-lg"></textarea>
</div>
<div class="flex items-center mb-4">
<input type="checkbox" id="useLlmForScript" class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 mr-2">
<label for="useLlmForScript" class="text-gray-700 text-sm">Usar LLM para generar/mejorar el guion (basado en fotos y en mi texto)</label>
</div>
<div id="llmMessage" class="message-box mt-4 rounded-lg"></div>
</div>
<!-- Sección 4: Selección de Voz -->
<div class="mb-8 p-4 bg-gray-50 rounded-xl shadow-inner">
<h2 class="text-2xl font-semibold mb-4 text-gray-700">4. Elige la Voz de tu Narrador</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="input-group">
<label for="voiceLanguage" class="block text-gray-700 text-sm font-bold mb-2">Idioma:</label>
<select id="voiceLanguage" class="rounded-lg">
<option value="es-MX">Español (México)</option>
<option value="en-US">Inglés (EE. UU.)</option>
<option value="pt-PT">Portugués (Portugal)</option>
<option value="pt-BR">Portugués (Brasil)</option>
</select>
</div>
<div class="input-group">
<label class="block text-gray-700 text-sm font-bold mb-2">Voz:</label>
<p class="text-sm text-gray-500 mt-1">Se utilizará una voz por defecto del servicio y idioma seleccionados (GTTS).</p>
</div>
</div>
<button id="testVoiceBtn" class="btn-primary rounded-xl">Probar Voz por Defecto</button>
<div id="voiceTestMessage" class="message-box mt-4 rounded-lg"></div>
</div>
<!-- Sección 5: Generación de Video -->
<div class="mb-8 text-center p-4 bg-gray-50 rounded-xl shadow-inner">
<h2 class="text-2xl font-semibold mb-4 text-gray-700">5. Generar Video</h2>
<button id="generateVideoBtn" class="btn-primary w-full max-w-xs rounded-xl">Crear Noticia en Video</button>
<div id="loadingSpinner" class="loading-spinner"></div>
<p id="loadingText" class="text-gray-600 mt-2" style="display: none;">Preparando tu video...</p>
<p class="text-sm text-gray-500 mt-1">Las imágenes se adaptarán y el audio se sincronizará profesionalmente con el video.</p>
<div id="generateVideoMessage" class="message-box mt-4 rounded-lg"></div>
</div>
<!-- Sección 6: Video Resultante -->
<div class="p-4 bg-gray-50 rounded-xl shadow-inner">
<h2 class="text-2xl font-semibold mb-4 text-gray-700">6. Tu Video está Listo</h2>
<div id="videoOutput" class="video-placeholder">
<p>El video generado aparecerá aquí.</p>
<video id="resultVideo" controls class="hidden rounded-xl"></video>
<a id="downloadVideoLink" href="#" download="noticia_ia.mp4" class="btn-primary mt-4 hidden rounded-xl">Descargar Video</a>
</div>
</div>
</div>
<!-- Modal personalizado para advertencias y confirmaciones -->
<div id="customModal" class="custom-modal-overlay">
<div class="custom-modal-content">
<h3 id="modalTitle">Atención</h3>
<p id="modalMessage">Este es un mensaje de prueba.</p>
<button id="modalCloseButton" class="rounded-lg">Entendido</button>
</div>
</div>
<script>
// Referencias a elementos del DOM
const llmServiceSelect = document.getElementById('llmService');
const genericApiKeyInput = document.getElementById('genericApiKey');
const customLlmEndpointGroup = document.getElementById('customLlmEndpointGroup');
const customLlmEndpointInput = document.getElementById('customLlmEndpoint');
const aspectRatio16_9Radio = document.getElementById('aspectRatio16_9');
const aspectRatio9_16Radio = document.getElementById('aspectRatio9_16');
const imageDurationInput = document.getElementById('imageDuration');
const imageDurationMessage = document.getElementById('imageDurationMessage');
const voiceServiceSelect = document.getElementById('voiceService');
const generateImagesWithAICheckbox = document.getElementById('generateImagesWithAI');
const imageGenerationOptionsDiv = document.getElementById('imageGenerationOptions');
const imageGenerationServiceSelect = document.getElementById('imageGenerationService');
const customImageGenEndpointGroup = document.getElementById('customImageGenEndpointGroup');
const customImageGenEndpointInput = document.getElementById('customImageGenEndpoint');
const imageGenerationPromptTextarea = document.getElementById('imageGenerationPrompt');
const imageGenMessage = document.getElementById('imageGenMessage');
const photoUpload = document.getElementById('photoUpload');
const thumbnailsContainer = document.getElementById('thumbnails');
const photoCountDisplay = document.getElementById('photoCount');
const imageErrorMessage = document.getElementById('imageErrorMessage');
const scriptTextarea = document.getElementById('scriptTextarea');
const useLlmForScriptCheckbox = document.getElementById('useLlmForScript');
const llmMessage = document.getElementById('llmMessage');
const voiceLanguageSelect = document.getElementById('voiceLanguage');
const testVoiceBtn = document.getElementById('testVoiceBtn');
const voiceTestMessage = document.getElementById('voiceTestMessage');
const generateVideoBtn = document.getElementById('generateVideoBtn');
const loadingSpinner = document.getElementById('loadingSpinner');
const loadingText = document.getElementById('loadingText');
const generateVideoMessage = document.getElementById('generateVideoMessage');
const videoOutput = document.getElementById('videoOutput');
const resultVideo = document.getElementById('resultVideo');
const downloadVideoLink = document.getElementById('downloadVideoLink');
const customModal = document.getElementById('customModal');
const modalTitle = document.getElementById('modalTitle');
const modalMessage = document.getElementById('modalMessage');
const modalCloseButton = document.getElementById('modalCloseButton');
let uploadedFiles = [];
// URL base del backend. En Hugging Face Spaces, si el backend está en el mismo espacio,
// esto a menudo es una cadena vacía para rutas relativas.
const BACKEND_URL = '';
// Función para mostrar el modal personalizado
function showCustomModal(title, message) {
modalTitle.textContent = title;
modalMessage.innerHTML = message;
customModal.classList.add('show');
}
// Función para ocultar el modal personalizado
function hideCustomModal() {
customModal.classList.remove('show');
}
// Event listener para cerrar el modal al hacer clic en el botón
modalCloseButton.addEventListener('click', hideCustomModal);
// Event listener para cerrar el modal al hacer clic fuera del contenido
customModal.addEventListener('click', (event) => {
if (event.target === customModal) {
hideCustomModal();
}
});
// Función para mostrar mensajes temporales en la UI
function showMessage(element, message, type = 'info', duration = 5000) {
element.textContent = message;
element.className = `message-box ${type}-message show rounded-lg`;
if (element.timeoutId) {
clearTimeout(element.timeoutId);
}
element.timeoutId = setTimeout(() => {
hideMessage(element);
}, duration);
}
// Función para ocultar mensajes temporales
function hideMessage(element) {
element.className = `message-box rounded-lg`;
element.textContent = '';
if (element.timeoutId) {
clearTimeout(element.timeoutId);
element.timeoutId = null;
}
}
// Función para actualizar la selección visual de la relación de aspecto
function updateAspectRatioSelection() {
document.querySelectorAll('input[name="aspectRatio"]').forEach(radio => {
const label = radio.closest('.aspect-ratio-option');
if (radio.checked) {
label.classList.add('selected');
} else {
label.classList.remove('selected');
}
});
// Ajustar la clase del contenedor de video para cambiar la relación de aspecto del placeholder
if (aspectRatio9_16Radio.checked) {
videoOutput.classList.add('aspect-9-16');
} else {
videoOutput.classList.remove('aspect-9-16');
}
}
// Event listener para mostrar/ocultar el campo de endpoint de LLM personalizado
llmServiceSelect.addEventListener('change', () => {
if (llmServiceSelect.value === 'custom') {
customLlmEndpointGroup.classList.remove('hidden');
} else {
customLlmEndpointGroup.classList.add('hidden');
customLlmEndpointInput.value = '';
}
});
// Event listener para mostrar/ocultar las opciones de generación de imagen por IA
generateImagesWithAICheckbox.addEventListener('change', () => {
if (generateImagesWithAICheckbox.checked) {
imageGenerationOptionsDiv.classList.remove('hidden');
} else {
imageGenerationOptionsDiv.classList.add('hidden');
imageGenerationPromptTextarea.value = '';
imageGenerationServiceSelect.value = 'google_imagen';
customImageGenEndpointGroup.classList.add('hidden');
customImageGenEndpointInput.value = '';
}
});
// Event listener para mostrar/ocultar el campo de endpoint de generación de imagen personalizado
imageGenerationServiceSelect.addEventListener('change', () => {
if (imageGenerationServiceSelect.value === 'custom_image_gen') {
customImageGenEndpointGroup.classList.remove('hidden');
} else {
customImageGenEndpointGroup.classList.add('hidden');
customImageGenEndpointInput.value = '';
}
});
// Validación en tiempo real para la duración de la imagen
imageDurationInput.addEventListener('input', () => {
const value = parseFloat(imageDurationInput.value);
if (isNaN(value) || value <= 0) {
showMessage(imageDurationMessage, 'La duración debe ser un número positivo (ej. 3 o 2.5).', 'error', 0);
} else {
hideMessage(imageDurationMessage);
}
});
// Función para manejar la carga de archivos de imagen y mostrar miniaturas
function handleFiles(files) {
hideMessage(imageErrorMessage);
thumbnailsContainer.innerHTML = 'Arrastra y suelta tus imágenes aquí o usa el botón de "Seleccionar imágenes".';
thumbnailsContainer.classList.remove('has-files');
uploadedFiles = [];
if (files.length === 0) {
photoCountDisplay.textContent = '0/10 fotos seleccionadas';
return;
}
if (files.length > 10) {
showCustomModal('Error de Fotos', 'Solo puedes subir un máximo de 10 fotos.');
photoUpload.value = '';
photoCountDisplay.textContent = '0/10 fotos seleccionadas';
return;
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
for (const file of files) {
if (!allowedTypes.includes(file.type)) {
showCustomModal('Error de Archivo', `El archivo "${file.name}" no es una imagen válida (JPG, PNG, GIF).`);
photoUpload.value = '';
photoCountDisplay.textContent = '0/10 fotos seleccionadas';
uploadedFiles = [];
return;
}
uploadedFiles.push(file);
}
photoCountDisplay.textContent = `${uploadedFiles.length}/10 fotos seleccionadas`;
thumbnailsContainer.innerHTML = '';
thumbnailsContainer.classList.add('has-files');
uploadedFiles.forEach(file => {
const reader = new FileReader();
reader.onload = (e) => {
const img = document.createElement('img');
img.src = e.target.result;
img.alt = `Miniatura de ${file.name}`;
img.classList.add('thumbnail');
thumbnailsContainer.appendChild(img);
};
reader.readAsDataURL(file);
});
}
// Event listener para el cambio en el input de archivo
photoUpload.addEventListener('change', (event) => handleFiles(event.target.files));
// Funcionalidad de arrastrar y soltar (drag and drop)
thumbnailsContainer.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
thumbnailsContainer.classList.add('drag-over');
});
thumbnailsContainer.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
thumbnailsContainer.classList.remove('drag-over');
});
thumbnailsContainer.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
thumbnailsContainer.classList.remove('drag-over');
const files = e.dataTransfer.files;
photoUpload.files = files;
handleFiles(files);
});
// Inicializar el estado de selección de la relación de aspecto al cargar la página
document.querySelectorAll('input[name="aspectRatio"]').forEach(radio => {
radio.addEventListener('change', updateAspectRatioSelection);
});
updateAspectRatioSelection();
// Event listener para probar la voz
testVoiceBtn.addEventListener('click', async () => {
hideMessage(voiceTestMessage);
const selectedLanguage = voiceLanguageSelect.value;
const selectedVoiceService = voiceServiceSelect.value;
const testText = "Hola, esta es una muestra de la voz por defecto.";
showMessage(voiceTestMessage, `Generando audio de prueba con ${selectedVoiceService} para "${selectedLanguage}"...`, 'info', 0);
try {
const response = await fetch(`${BACKEND_URL}/test-voice`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ text: testText, lang: selectedLanguage, service: selectedVoiceService })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Error desconocido al probar la voz.');
}
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.play();
showMessage(voiceTestMessage, 'Prueba de voz completada. Escuchando...', 'success');
} catch (error) {
console.error('Error durante la prueba de voz:', error);
showMessage(voiceTestMessage, `Error en la prueba de voz: ${error.message}. Revisa la consola para más detalles.`, 'error');
}
});
// Función principal para generar el video
generateVideoBtn.addEventListener('click', async () => {
// Limpiar mensajes y resetear el área de video
hideMessage(generateVideoMessage);
hideMessage(imageErrorMessage);
hideMessage(llmMessage);
videoOutput.innerHTML = '<p>El video generado aparecerá aquí.</p>';
resultVideo.classList.add('hidden');
resultVideo.src = '';
downloadVideoLink.classList.add('hidden');
// Recolectar valores de los inputs del usuario
const selectedLlmService = llmServiceSelect.value;
const genericApiKey = document.getElementById('genericApiKey').value.trim(); // Se recoge por si el backend lo requiere para local
const customLlmEndpoint = document.getElementById('customLlmEndpoint').value.trim(); // Se recoge por si el backend lo requiere para custom
const selectedAspectRatio = document.querySelector('input[name="aspectRatio"]:checked').value;
const imageDuration = parseFloat(imageDurationInput.value);
const selectedVoiceService = voiceServiceSelect.value;
const selectedLanguage = voiceLanguageSelect.value;
const shouldGenerateImages = generateImagesWithAICheckbox.checked;
const imageGenService = document.getElementById('imageGenerationService').value; // Se recoge por si el backend lo requiere
const customImageGenEndpoint = document.getElementById('customImageGenEndpoint').value.trim(); // Se recoge por si el backend lo requiere
const imageGenPrompt = imageGenerationPromptTextarea.value.trim();
const script = scriptTextarea.value.trim();
const useLlmForScript = useLlmForScriptCheckbox.checked;
// Validaciones de entrada del usuario con modal personalizado
if (uploadedFiles.length === 0 && !shouldGenerateImages) {
showCustomModal('Faltan Imágenes', 'Por favor, sube al menos una foto O marca la opción "Generar imágenes con IA" y proporciona un prompt.');
return;
}
if (uploadedFiles.length > 0 && shouldGenerateImages) {
showCustomModal('Conflicto de Imágenes', 'Has subido fotos Y activado la generación de imágenes con IA. Por favor, elige solo una opción.');
return;
}
if (shouldGenerateImages && !imageGenPrompt) {
showCustomModal('Prompt de Imagen Requerido', 'Para generar imágenes con IA, debes proporcionar un prompt descriptivo.');
return;
}
if (!script && !useLlmForScript) {
showCustomModal('Falta el Guion', 'Por favor, escribe un guion para tu noticia O marca la opción "Usar LLM para generar/mejorar el guion".');
return;
}
if (isNaN(imageDuration) || imageDuration <= 0) {
showCustomModal('Duración de Imagen Inválida', 'Por favor, introduce una duración de imagen válida (un número positivo) para cada segmento de video.');
return;
}
// Aunque el backend Python solo usará Gemini, estas validaciones son para el UI
if (useLlmForScript && selectedLlmService === 'custom' && !customLlmEndpoint) {
showCustomModal('URL de LLM Personalizado Requerida', 'Por favor, introduce la URL del endpoint de tu LLM Personalizado.');
return;
}
if (shouldGenerateImages && imageGenService === 'custom_image_gen' && !customImageGenEndpoint) {
showCustomModal('URL de Servicio Personalizado Requerida', 'Para usar un servicio de generación de imágenes personalizado, debes proporcionar su URL de endpoint.');
return;
}
// Mostrar spinner de carga y deshabilitar el botón
loadingSpinner.style.display = 'block';
loadingText.style.display = 'block';
generateVideoBtn.disabled = true;
try {
loadingText.textContent = 'Generando tu video con IA: texto, imágenes, audio y composición... esto puede tardar unos minutos.';
// Crear objeto FormData para enviar datos y archivos al backend
const formData = new FormData();
formData.append('script', script);
formData.append('useLlmForScript', useLlmForScript);
formData.append('llmService', selectedLlmService);
formData.append('customLlmEndpoint', customLlmEndpoint);
formData.append('aspectRatio', selectedAspectRatio);
formData.append('imageDuration', imageDuration);
formData.append('shouldGenerateImages', shouldGenerateImages);
formData.append('imageGenerationPrompt', imageGenPrompt);
formData.append('voiceService', selectedVoiceService);
formData.append('voiceLanguage', selectedLanguage);
// Si el genericApiKey se proporciona en el frontend, se envía.
// En despliegues de HF, el backend debe obtenerlo de los secrets.
if (genericApiKey) {
formData.append('genericApiKey', genericApiKey);
}
// Añadir archivos subidos al FormData
uploadedFiles.forEach((file, index) => {
formData.append(`photos`, file);
});
// Realizar la petición POST al backend
const response = await fetch(`${BACKEND_URL}/generate-video`, {
method: 'POST',
body: formData
});
// Manejar la respuesta del backend
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Error desconocido al generar el video.');
}
// Obtener el video como un Blob y crear una URL para mostrarlo
const videoBlob = await response.blob();
const videoToDisplayUrl = URL.createObjectURL(videoBlob);
// Mostrar el video en la interfaz de usuario
videoOutput.innerHTML = '';
const videoElement = document.createElement('video');
videoElement.id = 'resultVideo';
videoElement.controls = true;
videoElement.classList.remove('hidden');
videoElement.classList.add('w-full', 'h-full', 'object-contain', 'rounded-xl');
videoElement.src = videoToDisplayUrl;
videoElement.load();
videoOutput.appendChild(videoElement);
// Mostrar enlace de descarga
downloadVideoLink.href = videoToDisplayUrl;
downloadVideoLink.classList.remove('hidden');
showCustomModal('¡Vídeo Generado!', '¡Tu vídeo ha sido generado exitosamente! Puedes previsualizarlo o descargarlo.');
// Limpiar el formulario después de generar el video
scriptTextarea.value = '';
photoUpload.value = '';
uploadedFiles = [];
thumbnailsContainer.innerHTML = 'Arrastra y suelta tus imágenes aquí o usa el botón de "Seleccionar imágenes".';
thumbnailsContainer.classList.remove('has-files');
photoCountDisplay.textContent = '0/10 fotos seleccionadas';
imageGenerationPromptTextarea.value = '';
generateImagesWithAICheckbox.checked = false;
imageGenerationOptionsDiv.classList.add('hidden');
llmServiceSelect.value = 'gemini';
customLlmEndpointGroup.classList.add('hidden');
customLlmEndpointInput.value = '';
useLlmForScriptCheckbox.checked = false;
} catch (error) {
console.error('Error al generar el vídeo:', error);
showCustomModal('Error al Generar Vídeo', `Hubo un error inesperado al generar el vídeo: ${error.message}. Por favor, intenta de nuevo y revisa la consola del navegador y del servidor para más detalles.`);
} finally {
// Ocultar spinner y habilitar el botón al finalizar (éxito o error)
loadingSpinner.style.display = 'none';
loadingText.style.display = 'none';
generateVideoBtn.disabled = false;
}
});
// Asegurarse de que la selección de relación de aspecto se inicialice al cargar
updateAspectRatioSelection();
</script>
</body>
</html>