Spaces:
Runtime error
Runtime error
<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> | |