Índice de contenido
- Filosofía del Entorno: Privacidad y Ejecución Local
- Estructura del Backend con FastAPI
- Orquestación de Agentes con CrewAI
- El Problema de la Estructura Variante y el No-Determinismo
- Refactorización de la Arquitectura: Hacia un Enfoque Híbrido
- El Nuevo Flujo de Trabajo Operativo
- Moraleja Arquitectónica
- Hyperframes para generar videos + FastAPI
- Arquitectura del servicio en FastAPI
- Vista GET para seleccionar la plantilla de hyperframes y variar colores y texto
- Procesamiento de plantillas dinámicas y sustitución de parámetros
- Compilación y renderizado desde la línea de comandos
- Creación de plantillas asistida por IA
- Conclusiones
- Construcción de un Sistema RAG Local para Consultar Libros Técnicos
- Dependencias Necesarias
- Fase 1: Conversión de HTML a Markdown y Generación de Metadatos
- Fase 2: Fragmentación de Documentos (Chunking) y Vectorización
- Fase 3: Almacenamiento Vectorial en PostgreSQL con pgvector y SQLAlchemy
- Fase 4: Consulta Semántica y Búsqueda por Distancia de Coseno
- Diferencia entre RAG para Consulta y Flujos de Generación Automatizada
- Desarrollo de un Editor de Video Local impulsado por Inteligencia Artificial
- 1. Flujo de Trabajo y Eliminación Automatizada de Silencios
- 2. Generación Automatizada de Contenido y Metadatos
- 3. Adaptación a Formato Vertical (Shorts / Reels)
- 4. Subtitulado Automático y Animaciones con Manim
- Conclusión
En este post, analizaremos el proceso de diseño y desarrollo de un "cerebro digital", un sistema concebido para actuar como un asistente avanzado en la generación automatizada de contenidos, específicamente enfocado en la creación de presentaciones estructurales. Este proyecto debe abordarse bajo la filosofía de una bitácora de desarrollo evolutiva, lo que implica que la arquitectura pasa por múltiples fases experimentales y refactorizaciones continuas antes de consolidar una versión óptima para producción.
Filosofía del Entorno: Privacidad y Ejecución Local
El pilar de este cerebro digital es la ejecución 100% local. Priorizar que los Modelos de Lenguaje Grande (LLM) se ejecuten localmente elimina la dependencia de APIs de terceros, garantiza la privacidad absoluta de los datos procesados y suprime los costos operativos variables asociados al consumo de tokens externos.
Para la fase inicial de pruebas, se integró el framework Hermes a través de una interfaz de chat conversacional para evaluar la viabilidad del código generado. Sin embargo, para mitigar el ruido sintáctico y la rigidez de depender de herramientas externas, el flujo evolucionó hacia una implementación backend completamente personalizada utilizando el ecosistema de Python.
Estructura del Backend con FastAPI
Python es el lenguaje estándar en el ecosistema de la Inteligencia Artificial, lo que fundamenta su elección para este proyecto. Como framework de desarrollo web se seleccionó FastAPI, debido a su alto rendimiento, soporte nativo para operaciones asíncronas y su capacidad para estructurar servicios modulares basados en APIs de manera limpia y escalable.
La arquitectura del backend se organiza bajo la siguiente estructura de directorios y responsabilidades:
- Core / Models: Definición de las estructuras de datos y configuraciones base del sistema.
- Agents: Módulos encargados de la lógica de los agentes de Inteligencia Artificial.
- API / Endpoints: Rutas expuestas (como el endpoint
/generate) para comunicar la interfaz de usuario con los servicios internos.
brain-app/
├── app/
│ ├── __init__.py
│ ├── main.py # Punto de entrada de FastAPI
│ ├── core/ # Configuración y conexión a Ollama/APIs
│ │ └── config.py
│ ├── agents/ # Aquí vivirán mis scripts de CrewAI (JSON, HTML, Flux)
│ │ └── __init__.py
│ ├── tools/ # Skills personalizadas de Python (guardar archivos, etc.)
│ │ └── __init__.py
│ ├── templates/ # Vistas HTML (Frontend del tablero)
│ │ └── index.html
│ └── static/ # CSS y JS estáticos
│ ├── css/
│ └── js/
├── requirements.txt # Dependencias (fastapi, uvicorn, jinja2, crewai)
└── .env # Variables de entorno (URLs de Ollama, Flux, etc.)Orquestación de Agentes con CrewAI
Para coordinar el comportamiento de los modelos locales, se implementó CrewAI, un framework diseñado específicamente para la orquestación de agentes de inteligencia artificial. CrewAI permite definir roles, asignar herramientas (skills) y encadenar tareas de forma secuencial.
En el primer diseño experimental, la generación se dividió en dos agentes secuenciales utilizando un modelo de la familia Llama ejecutado localmente:
- Agente de Contenido: Recibe el tópico del usuario (por ejemplo, "Sistemas de enrutamiento en Laravel"), analiza el tema y genera la información estructurada en un formato de intercambio de datos (JSON).
- Agente de Maquetación: Toma el JSON producido por el agente anterior y se encarga de estructurar el código HTML y CSS final de la presentación, aplicando estilos y paletas de colores específicas.
Para evitar la saturación del contexto del modelo, el flujo se configuró en modo secuencial y detallado (verbose=True) para auditar el comportamiento de los agentes directamente en la consola del servidor.
Los agentes con Crew:
app/agents/slides_crew.py
from crewai import LLM, Agent, Crew, Process, Task
# =====================================================================
# 1. CONEXIÓN DIRECTA A TU OLLAMA LOCAL (127.0.0.1)
# =====================================================================
# Llama 3 8B para el procesamiento lógico y estructuración del JSON
llm_json = LLM(
# model="ollama/llama3-default",
model="ollama/gemma3:12b",
base_url="http://127.0.0.1:11434"
)
# Gemma 4 12B para la maquetación visual UI/UX premium en HTML
llm_html = LLM(
# model="ollama/gemma4-slides",
model="ollama/gemma3:12b",
base_url="http://127.0.0.1:11434"
)
# =====================================================================
# 2. DEFINICIÓN DE AGENTES ATÓMICOS (Sin herramientas heredadas)
# =====================================================================
arquitecto_json = Agent(
role='Arquitecto de Contenido y Datos JSON',
goal='Sintetizar temas complejos de desarrollo en un formato JSON estructurado rígido.',
backstory='Eres un desarrollador backend senior meticuloso. Tu único trabajo es crear la estructura de datos sin preocuparte por el diseño visual.',
verbose=True,
llm=llm_json
)
disenador_html = Agent(
role='Desarrollador Frontend UI/UX Senior',
goal='Tomar estructuras de datos JSON e inyectarlas en plantillas HTML/CSS oscuras interactivas.',
backstory='Eres un diseñador web experto en modo oscuro. Sigues estrictamente la paleta oscura (#0f172a), textos blancos, detalles cian y bordes redondeados (12px).',
verbose=True,
llm=llm_html
)
# =====================================================================
# 3. FUNCIÓN DE ORQUESTACIÓN PARA TU BACKEND
# =====================================================================
def ejecutar_pipeline_slides(tema: str, ruta_salida: str = "app/static/presentacion.html") -> str:
"""
Función que recibe un tema, ejecuta la tubería de agentes locales
y guarda el resultado final en la carpeta estática de tu app FastAPI.
"""
# Tarea 1: Generar el formato de intercambio (JSON)
tarea_json = Task(
description=(
f"Analiza el tema: '{tema}'. "
"Crea una presentación de 4 diapositivas estructuradas técnicamente. "
"Devuelve ÚNICAMENTE un objeto JSON con esta estructura exacta:\n"
"{{\n"
" \"titulo_general\": \"...\",\n"
" \"slides\": [\n"
" {{\"slide\": 1, \"titulo\": \"...\", \"puntos\": [\"...\", \"...\"]}},\n"
" ... \n"
" ]\n"
"}}\n"
"⚠️ REGLA CRÍTICA: No incluyas bloques de código Markdown (```json), saludos ni texto extra. Solo el string JSON puro."
),
expected_output="Un string JSON válido y limpio.",
agent=arquitecto_json
)
# Tarea 2: Consumir el JSON y renderizar el frontend interactivo
tarea_html = Task(
description=(
"Toma el JSON generado en la tarea anterior. "
"Genera una estructura de presentación HTML interactiva e independiente.\n\n"
"Reglas visuales estrictas:\n"
"- Fondo del body: #0f172a\n"
"- Tarjetas de las diapositivas: fondo #1e1e24, padding amplio, border-radius de 12px.\n"
"- Tipografía: Texto en blanco (#ffffff) y destacados técnicos en cian (#22d3ee).\n"
"- Agrega un script simple (<script>) para pasar de diapositiva usando las flechas del teclado."
),
expected_output="Código HTML completo, semántico y listo para producción.",
agent=disenador_html,
output_file=ruta_salida # Escribe el archivo directo en tu static de FastAPI
)
# El Cerebro que secuencia el proceso
crew = Crew(
agents=[arquitecto_json, disenador_html],
tasks=[tarea_json, tarea_html],
process=Process.sequential, # Secuencial estricto: Tarea 2 depende de la Tarea 1
verbose=True
)
# Ejecutamos pasando el parámetro dinámico
#crew.kickoff_async(inputs={'tema': tema})
crew.kickoff(inputs={'tema': tema})
return ruta_salidaEl formulario:
app/api/slides.py
from __future__ import annotations
import os
from fastapi import APIRouter, Form, HTTPException
from fastapi.responses import RedirectResponse
from app.agents.slides_crew import ejecutar_pipeline_slides
router = APIRouter()
@router.post("/generar", name="api_slides_generate")
def generar_presentacion(tema: str = Form(...)) -> RedirectResponse:
try:
os.makedirs("app/static", exist_ok=True)
print(f"Slides Router: Activando agentes locales para: {tema}")
ejecutar_pipeline_slides(tema=tema)
return RedirectResponse(url="/static/presentacion.html", status_code=303)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Error en los agentes de slides: {str(e)}",
) from eY la presentación de la vista con GET:
app/routers/pages.py
@router.get("/agents/slides", response_class=HTMLResponse, name="agents_slides")
async def slides_form(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="agents/slides.html",
context={"page_title": "Generate Slides"},
)Su template:
{% extends "base.html" %}
{% block content %}
<div class="max-w-2xl mx-auto">
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900">Generate Presentation</h1>
<p class="text-gray-500 mt-1">
Describe the topic and the AI agent pipeline will create a complete HTML slideshow.
</p>
</div>
<form action="{{ url_for('api_slides_generate') }}" method="POST" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="mb-4">
<label for="tema" class="block text-sm font-medium text-gray-700 mb-2">
Presentation Topic
</label>
<textarea
id="tema"
name="tema"
rows="5"
required
placeholder="e.g. Introduccion a la inteligencia artificial con Python..."
class="w-full px-4 py-3 rounded-lg border border-gray-300 text-sm placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500
transition resize-y"
></textarea>
<p class="mt-2 text-xs text-gray-400">
The agents will structure the content, generate JSON, and build a dark-themed HTML presentation.
</p>
</div>
<div class="flex items-center gap-3">
<button
type="submit"
id="submit-btn"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium
hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2
transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg id="spinner" class="w-4 h-4 hidden animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span id="btn-text">Generate</span>
</button>
<a href="{{ url_for('agents_list') }}" class="text-sm text-gray-500 hover:text-gray-700 transition">Cancel</a>
</div>
</form>
<div class="mt-8 bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-sm font-semibold text-gray-900 uppercase tracking-wide mb-3">Pipeline</h2>
<div class="space-y-3">
<div class="flex items-start gap-3">
<div class="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<span class="text-xs font-bold text-indigo-600">1</span>
</div>
<div>
<p class="text-sm font-medium text-gray-900">JSON Structurer Agent</p>
<p class="text-xs text-gray-500">Llama 3 — organizes the topic into a rigid JSON outline.</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-6 h-6 rounded-full bg-emerald-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<span class="text-xs font-bold text-emerald-600">2</span>
</div>
<div>
<p class="text-sm font-medium text-gray-900">HTML Designer Agent</p>
<p class="text-xs text-gray-500">Gemma 4 — injects JSON into a dark-themed interactive HTML template.</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<span class="text-xs font-bold text-amber-600">3</span>
</div>
<div>
<p class="text-sm font-medium text-gray-900">Output</p>
<p class="text-xs text-gray-500">Saved to <code class="text-amber-700 bg-amber-50 px-1 rounded">/static/presentacion.html</code></p>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
var form = document.querySelector("form");
var btn = document.getElementById("submit-btn");
var spinner = document.getElementById("spinner");
var btnText = document.getElementById("btn-text");
if (form && btn) {
form.addEventListener("submit", function () {
btn.disabled = true;
spinner.classList.remove("hidden");
btnText.textContent = "Generating...";
});
}
});
</script>
{% endblock %}El Problema de la Estructura Variante y el No-Determinismo
La primera aproximación que hicimos antes, el uso de doble agente reveló una limitación crítica ligada a la naturaleza de los LLM. La Inteligencia Artificial no es determinista, sino probabilística. Esto significa que, ante una misma instrucción exacta, el segundo agente generaba estructuras HTML inconsistentes, variables y propensas a errores de renderizado.
Adicionalmente, las respuestas de los modelos locales suelen incluir delimitadores de texto adicionales o bloques de código mal formados, lo que rompe el parseo estricto del JSON e introduce inestabilidad en el hilo principal de ejecución, especialmente si la petición se procesa de forma síncrona sin delegar el flujo a JavaScript en el cliente.
Refactorización de la Arquitectura: Hacia un Enfoque Híbrido
Para solucionar la inconsistencia estética y estructural (HTML Y CSS), se rediseñó el flujo eliminando por completo al segundo agente. En su lugar, se implementó un enfoque híbrido que combina la capacidad analítica de la IA con la rigidez del desarrollo programático tradicional.
El Nuevo Flujo de Trabajo Operativo
El sistema actual opera bajo un proceso optimizado de dos pasos que introduce una capa de validación humana intermedia:
- Paso 1: Extracción Estructurada (IA): Un único agente de CrewAI procesa el tema y devuelve exclusivamente un esquema JSON limpio que contiene el texto de las diapositivas.
- Paso 2: Edición e Inyección en Plantilla (Programático): El backend de FastAPI recibe este JSON y lo despliega en un formulario web interactivo. El usuario puede auditar, corregir o expandir el texto directamente (por ejemplo, cambiar el título a "Rutas en Laravel 13"). Una vez aprobado, Python toma estos datos corregidos y los inyecta mediante código estructurado dentro de una plantilla HTML fija y predefinida.
app/agents/slides_crew.py
# =====================================================================
# FASE 1: Generar la propuesta limpia en JSON
# =====================================================================
def generar_estructura_json(tema: str) -> Any:
tarea_json = Task(
description=(
f"Analiza el tema: '{tema}'. Crea una estructura de 4 diapositivas técnicas. "
"Devuelve ÚNICAMENTE un objeto JSON puro con este formato:\n"
"{{\n"
' "titulo_general": "...",\n'
' "slides": [\n'
' {{"slide": 1, "titulo": "...", "puntos": ["...", "..."]}}\n'
" ]\n"
"}}\n"
"⚠️ REGLA: No incluyas bloques ```json ni texto extra."
),
expected_output="Un string JSON válido.",
agent=arquitecto_json,
)
# Crew minúscula de un solo agente para la fase 1
crew_fase1 = Crew(agents=[arquitecto_json], tasks=[tarea_json], verbose=True)
resultado = crew_fase1.kickoff(inputs={"tema": tema})
# Limpiamos la respuesta cruda del LLM antes de parsear
return parsear_json_ia(str(resultado))Para limpiar el JSON:
utils.py
from __future__ import annotations
import re
from typing import Any
def limpiar_json(texto: str) -> str:
texto = texto.strip()
if match := re.search(r"```(?:json)?\s*\n?(.*?)\n?```", texto, re.DOTALL):
texto = match.group(1).strip()
if texto.startswith("```json"):
texto = texto[7:]
elif texto.startswith("```"):
texto = texto[3:]
if texto.endswith("```"):
texto = texto[:-3]
inicio = texto.find("{")
fin = texto.rfind("}")
if inicio != -1 and fin != -1 and fin > inicio:
texto = texto[inicio : fin + 1]
return texto.strip()
def parsear_json_ia(texto: str) -> Any:
import json
texto_limpio = limpiar_json(texto)
return json.loads(texto_limpio)Ahora, tenemos dos procesos de formularios, uno para preguntar el tema y otro para devolver el JSON con la posibilidad de que el usuario pueda actualizar el mismo y que lo deje justo como necesite:
@router.post("/step1", name="api_slides_step1")
def step1(request: Request, tema: str = Form(...)) -> HTMLResponse:
try:
datos_json = generar_estructura_json(tema=tema)
json_bonito = json.dumps(datos_json, indent=4, ensure_ascii=False)
return templates.TemplateResponse(
request=request,
name="agents/slides/steps.html",
context={"json_borrador": json_bonito, "tema_actual": tema},
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Fallo en Fase 1: {str(e)}") from e
@router.post("/step2", name="api_slides_step2")
def step2(request: Request, json_editado: str = Form(...)) -> RedirectResponse:
try:
os.makedirs("app/static", exist_ok=True)
# 1. Convertimos el string del textarea editado en un diccionario real de Python
datos_dict = json.loads(json_editado)
# 2. Cargamos el template físico aparte y lo inyectamos con los datos del JSON
template_slides = templates.get_template("agents/slides/_structure.html")
html_compilado = template_slides.render(request=request, **datos_dict)
# 3. Guardamos el resultado final en la carpeta static
ruta_salida = "app/static/presentacion.html"
with open(ruta_salida, "w", encoding="utf-8") as f:
f.write(html_compilado)
# Redirección directa al archivo estático recién horneado
return RedirectResponse(url="/static/presentacion.html", status_code=303)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="El contenido editado no es un JSON válido. Revisa las comas o corchetes.")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Fallo en Fase 2 (Renderizado): {str(e)}") from eEl template usado por ambos pasos:
{% extends "base.html" %}
{% block content %}
<section class="max-w-2xl mx-auto bg-gray-900 rounded-xl border border-gray-800 p-6 flex flex-col gap-6">
<form id="step1-form"" action="{{ url_for('api_slides_step1') }}" method="POST" class="flex flex-col gap-3">
<label class="text-xs font-semibold uppercase text-gray-400">1. Define the Topic</label>
<input type="text" name="tema" value="{{ tema_actual if tema_actual else '' }}" required
placeholder="e.g. Arquitectura limpia en Python"
class="w-full bg-gray-950 border border-gray-800 rounded-lg p-3 text-sm text-white focus:border-cyan-500 outline-none">
<button type="submit"
class="w-full bg-gray-800 hover:bg-gray-700 text-cyan-400 font-medium text-sm py-2.5 rounded-lg border border-gray-700 transition-all">
Propose Structure (JSON)
</button>
</form>
{% if json_borrador %}
<!-- PASO 2 -->
<hr class="border-gray-800">
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<label class="text-xs font-semibold uppercase text-gray-400">2. Review & Edit Structure</label>
<span class="text-[10px] bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 px-2 py-0.5 rounded">Draft Ready</span>
</div>
<p class="text-xs text-gray-500">You can edit the JSON directly below. When ready, click to generate the final HTML presentation.</p>
<div id="step2-error" class="hidden p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700"></div>
<form id="step2-form" action="{{ url_for('api_slides_step2') }}" method="POST" class="flex flex-col gap-3">
<textarea name="json_editado" rows="15"
class="w-full bg-gray-950 border border-gray-800 rounded-lg p-3 font-mono text-xs text-emerald-400 focus:border-emerald-500 outline-none leading-relaxed">{{ json_borrador }}</textarea>
<button type="submit" id="step2-btn"
class="w-full bg-cyan-600 hover:bg-cyan-500 text-white font-medium text-sm py-2.5 rounded-lg transition-all
shadow-[0_4px_12px_rgba(34,211,238,0.2)] disabled:opacity-50 disabled:cursor-not-allowed">
<svg id="step2-spinner" class="w-4 h-4 hidden animate-spin inline" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span id="step2-btn-text">Approve & Render Final Dashboard</span>
</button>
</form>
</div>
{% endif %}
</section>
<script>
document.addEventListener("DOMContentLoaded", function () {
var form = document.getElementById("step2-form");
if (!form) return;
var btn = document.getElementById("step2-btn");
var spinner = document.getElementById("step2-spinner");
var btnText = document.getElementById("step2-btn-text");
var errorEl = document.getElementById("step2-error");
form.addEventListener("submit", async function (e) {
e.preventDefault();
btn.disabled = true;
spinner.classList.remove("hidden");
btnText.textContent = "Rendering...";
if (errorEl) errorEl.classList.add("hidden");
try {
var resp = await fetch(form.action, {
method: "POST",
body: new FormData(form),
});
if (!resp.ok) {
var data = await resp.json().catch(function () { return {}; });
throw new Error(data.detail || "Render failed");
}
window.location.href = resp.url;
} catch (err) {
if (errorEl) {
errorEl.textContent = err.message;
errorEl.classList.remove("hidden");
}
btn.disabled = false;
spinner.classList.add("hidden");
btnText.textContent = "Approve & Render Final Dashboard";
}
});
});
</script>
{% endblock %}Como puedes ver en el paso dos:
templates.get_template("agents/slides/_structure.html")Usamos una estructura para de manera programática, tomamos en JSON en INMEDIATAMENTE, generamos la presentación, ahorrándonos el uso de un agente con lo que esto conlleva y ahora SIEMPRE tenemos la misma estructura para la presentación:
<div class="presentation-wrapper">
{% for slide in slides %}
<div class="slide-container {% if loop.first %}active{% endif %}" id="slide-{{ loop.index }}">
<div class="slide-title">{{ slide.titulo }}</div>
<div class="bullet-list">
<ul>
{% for punto in slide.puntos %}
<li>
<i class="fa-solid fa-square-terminal"></i>
{{ punto }}
</li>
{% endfor %}
</ul>
</div>
</div>
{% endfor %}
</div>Moraleja Arquitectónica
No todo el software debe resolverse utilizando Inteligencia Artificial. Los procesos que exigen consistencia, predictibilidad, maquetación visual y control de animaciones deben resolverse de forma programática utilizando plantillas y código tradicional. La Inteligencia Artificial debe reservarse estrictamente para tareas creativas, de extracción de información y de procesamiento de lenguaje natural.
Hyperframes para generar videos + FastAPI
En este apartadp analizaremos cómo automatizar la producción de video utilizando plantillas web mediante herramientas de renderizado programáticocon Hyperframes integradas con un backend en FastAPI.
La ventaja principal de este enfoque radica en la consistencia visual: mientras que generar animaciones desde cero con IA suele producir variaciones indeseadas en tipografías y estilos, el uso de plantillas estructuradas garantiza un diseño uniforme en cada ejecución.
Esta arquitectura forma parte de un ecosistema de automatización («cerebro digital») enfocado en la generación de activos multimedia para contenidos técnicos y redes sociales.
Arquitectura del servicio en FastAPI
El servicio backend se estructura en torno a tres componentes esenciales dentro del proyecto:
- Rutas de API (Endpoints): Puntos de entrada HTTP encargados de recibir los parámetros del usuario (textos, paletas de color y selección de plantilla).
- Gestor de Plantillas (Templates): Archivos HTML/CSS y componentes de animación que contienen las variables dinámicas de renderizado.
- Agentes y Ejecución de Comandos: Módulos encargados de procesar la lógica y ejecutar las llamadas del sistema para compilar el video final.
Vista GET para seleccionar la plantilla de hyperframes y variar colores y texto
Primero, tenemos un endpoint con su vista para seleccionar la plantilla:
app/templates/hyperframes/form.html
<section class="max-w-2xl mx-auto bg-gray-900 rounded-xl border border-gray-800 p-6 flex flex-col gap-6">
<div>
<h1 class="text-xl font-bold text-white">HyperFrames Video Generator</h1>
<p class="text-sm text-gray-400 mt-1">
Select a project template, customize the text and colors, then render an MP4 video using HyperFrames.
</p>
</div>
{% raw %}
<div id="app-hyperframes">
<form @submit.prevent="submitForm" class="flex flex-col gap-4">
<div>
<label class="text-xs font-semibold uppercase text-gray-400 block mb-1">Template</label>
<select v-model="templateName" required
class="w-full bg-gray-950 border border-gray-800 rounded-lg p-3 text-sm text-white
focus:border-cyan-500 outline-none">
<option value="">-- Select a template --</option>
<option v-for="t in templates" :key="t" :value="t">{{ t }}</option>
</select>
</div>
<div>
<label class="text-xs font-semibold uppercase text-gray-400 block mb-1">Title / Text</label>
<input type="text" v-model="titleText" required
placeholder="e.g. New chapter available!"
class="w-full bg-gray-950 border border-gray-800 rounded-lg p-3 text-sm text-white
focus:border-cyan-500 outline-none">
</div>
<div>
<label class="text-xs font-semibold uppercase text-gray-400 block mb-1">Background Color</label>
<input type="color" v-model="bgColor"
class="w-full h-12 bg-gray-950 border border-gray-800 rounded-lg p-1 cursor-pointer">
</div>
<div>
<label class="text-xs font-semibold uppercase text-gray-400 block mb-1">Text Color</label>
<input type="color" v-model="textColor"
class="w-full h-12 bg-gray-950 border border-gray-800 rounded-lg p-1 cursor-pointer">
</div>
<div v-if="error" class="p-3 rounded-lg bg-red-900/50 border border-red-700 text-sm text-red-300">{{ error }}</div>
<button type="submit" :disabled="!templateName || !titleText || loading"
class="w-full bg-cyan-600 hover:bg-cyan-500 text-white font-medium text-sm py-2.5 rounded-lg transition-all
shadow-[0_4px_12px_rgba(34,211,238,0.2)] disabled:opacity-50 disabled:cursor-not-allowed">
<svg v-show="loading" class="w-4 h-4 animate-spin inline" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span>{{ loading ? 'Rendering...' : 'Generate MP4 Video' }}</span>
</button>
</form>
<div v-if="videoUrl" class="mt-4 bg-gray-950 border border-gray-800 rounded-lg p-4 text-center">
<p class="text-xs font-semibold uppercase text-gray-400 mb-2">Video Generated</p>
<video :src="videoUrl" controls class="w-full rounded-lg mb-2"></video>
<a :href="videoUrl" target="_blank" class="text-cyan-400 text-sm hover:underline">Open video in new tab</a>
</div>
</div>
{% endraw %}
</section>
***
// Load templates on mount
fetch("{{ url_for('hyperframes_templates') }}")
.then(function (r) { return r.json(); })
.then(function (data) { templates.value = data; })
.catch(function (err) { error.value = "Failed to load templates: " + err.message; });El listado de plantillas de hyperframes, lo obtenemos desde otro endpoint que es el que consumimos en el fetch anterior:
app/api/hyperframes.py
@router.get("/hyperframes/templates", response_class=JSONResponse, name="hyperframes_templates")
def list_templates() -> list[str]:
if not TEMPLATES_DIR.exists():
return []
return sorted(d.name for d in TEMPLATES_DIR.iterdir() if d.is_dir())Y la petición web:
app/api/hyperframes.py
@router.get("/hyperframes", response_class=HTMLResponse, name="hyperframes_form")
def show_hyperframes_form(request: Request) -> HTMLResponse:
templates_list: list[str] = []
if TEMPLATES_DIR.exists():
templates_list = sorted(d.name for d in TEMPLATES_DIR.iterdir() if d.is_dir())
return templates.TemplateResponse(
request=request,
name="hyperframes/form.html",
context={"request": request, "templates": templates_list},
)Al consumir el endpoint principal (por ejemplo, POST /render), la aplicación valida los datos ingresados desde la interfaz reemplaza los identificadores dinámicos dentro de la plantilla seleccionada y procesa la salida hacia un archivo de video MP4:
app/api/hyperframes.py
@router.post("/hyperframes/render", response_class=JSONResponse, name="hyperframes_render")
def render_video(data: RenderRequest) -> dict[str, object]:
try:
result = render_video_job(
data.template_name,
data.title_text,
data.bg_color,
data.text_color,
)
return {"status": "success", **result}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e)) from eProcesamiento de plantillas dinámicas y sustitución de parámetros
A diferencia de los flujos basados puramente en IA generativa, esta metodología utiliza archivos de plantilla fijos que incluyen marcadores de posición para las variables que se desean personalizar:
if html_file.exists():
content = html_file.read_text(encoding="utf-8")
content = content.replace("{{TITLE_TEXT}}", title_text)
content = content.replace("{{BG_COLOR}}", bg_color)
content = content.replace("{{TEXT_COLOR}}", text_color)
html_file.write_text(content, encoding="utf-8")Al mantener la lógica de diseño dentro de la plantilla y aislar únicamente los datos de entrada, se elimina el consumo innecesario de tokens en llamadas recurrentes a la IA. Una vez diseñada la estructura base, todo el proceso de compilación se ejecuta de manera local y gratuita en el servidor.
Compilación y renderizado desde la línea de comandos
El núcleo del renderizado consiste en invocar la herramienta CLI de generación de vídeo directamente desde Python mediante la ejecución de procesos del sistema. A continuación se muestra la secuencia habitual para inicializar y compilar un proyecto desde el backend:
# Creación e inicialización del proyecto de video desde la consola
npx hyperframes create mi-plantilla --template minimalist
# Ejecución del renderizado desde Python pasando la plantilla procesada
import subprocess
command = ["npx", "hyperframes", "render", "path/to/template.html", "--output", "output/video.mp4"]
subprocess.run(command, check=True)
Antes de integrar la llamada en el código de FastAPI, es imprescindible probar la compilación de forma manual en la terminal para asegurar que todas las dependencias del motor de renderizado (como instancias de navegadores headless o paquetes de animación) se hayan descargado e instalado correctamente.
El método para seleccionar la plantilla, cambiar los colores y textos y generar el vídeo mediante hyperframes que llamamos en el POST queda como:
app/api/hyperframes.py
def render_video_job(
template_name: str,
title_text: str,
bg_color: str = "#0f172a",
text_color: str = "#ffe600",
) -> dict[str, str]:
"""Renderiza un video HyperFrames y devuelve la info del job.
Extraído para que el router agent pueda llamarlo directamente.
"""
template_path = (TEMPLATES_DIR / template_name).resolve()
if not template_path.exists() or not str(template_path).startswith(
str(TEMPLATES_DIR.resolve())
):
raise FileNotFoundError(f"Plantilla '{template_name}' no encontrada.")
job_id = str(uuid.uuid4())[:8]
work_dir = APP_DIR / "tmp" / f"job_{job_id}"
shutil.copytree(template_path, work_dir)
try:
html_file = work_dir / "index.html"
if html_file.exists():
content = html_file.read_text(encoding="utf-8")
content = content.replace("{{TITLE_TEXT}}", title_text)
content = content.replace("{{BG_COLOR}}", bg_color)
content = content.replace("{{TEXT_COLOR}}", text_color)
html_file.write_text(content, encoding="utf-8")
design_file = work_dir / "DESIGN.md"
if design_file.exists():
design_content = f"# Dynamic Design\n- Primary: {bg_color}\n"
design_file.write_text(design_content, encoding="utf-8")
output_filename = f"video_{job_id}.mp4"
output_path = OUTPUTS_DIR / output_filename
cmd = [
"npx",
"hyperframes",
"render",
"--output",
str(output_path),
"--quality",
"standard",
]
subprocess.run(cmd, cwd=work_dir, capture_output=True, text=True, check=True)
return {
"job_id": job_id,
"video_url": f"/static/outputs/{output_filename}",
}
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Error al renderizar el video: {e.stderr}") from e
finally:
if work_dir.exists():
shutil.rmtree(work_dir)Creación de plantillas asistida por IA
Para construir la estructura inicial de las plantillas de animación sin necesidad de redactar manualmente todas las propiedades de rotación, opacidad o escalado CSS, se pueden emplear asistentes de código (como los disponibles en herramientas local-first u OpenCode) mediante un prompt bien estructurado que generé mediante Gemini (como siempre, puedes usar algún chat de IA para asistente para generar estos prompts y problemas de implementación:
Crea una plantilla HTML/CSS/JS para Hyperframes que anime un texto dinámico de forma hiperenfática (palabras/letras por bloques) sobre un fondo de alto contraste.
Especificaciones técnicas y visuales:
Mecánica de Animación:
Recibe un texto dinámico en una variable {{TITLE_TEXT}}.
Divide el texto automáticamente y muestra únicamente 2 letras a la vez (o grupos de 2-3 caracteres) secuencialmente en el centro de la pantalla en tamaño gigante (min. 120px o 12vw), simulando un efecto de impacto tipo "Word-by-Word / Kinetic Typography".
La animación debe manejarse con GSAP sobre el timeline principal global window.__timelines['main'].
Arquitectura para Renderizado Headless (Puppeteer/Seek):
Prohibido: No uses tl.call() con concatenación imperativa de DOM, ni animaciones CSS nativas (@keyframes).
Requerido: El HTML debe parsear e inyectar previamente todos los pares de letras como elementos <span> independientes dentro del DOM, manejando la visibilidad/aparición mediante interpolación pura de propiedades CSS interpolables (opacity: 0 a opacity: 1, scale, o display/visibility mapeados de forma determinista). Cada par de letras debe tener su estado calculable con un seek(t).
Estilo, Fondo y Contraste:
Incluye variables configurables para el color de fondo {{BG_COLOR}} y el color del texto {{TEXT_COLOR}}.
Implementa una regla en CSS/JS para garantizar alto contraste (ej. fondo oscuro #0f0f11 con texto brillante #ffffff o amarillo eléctrico #ffe600, o un contenedor sólido de respaldo detrás de cada par de letras).
Tipografía sans-serif bold/heavy (tipo Montserrat Black, Impact o Inter ExtraBold) en mayúsculas.
Entregable:
Código completo en un solo archivo con la estructura HTML, estilos CSS embebidos y el script de GSAP listo para ser procesado cuadro por cuadro.
Una vez generada la plantilla base, esta se almacena en el directorio estático del proyecto (app/static/templates) para ser reutilizada ilimitadas veces desde el panel de control de la aplicación.
Antes de probar la implementación, debes de instalar hyperframe:
$ npm install -g hyperframesY genera algún vídeo mediante la terminal para que descargue dependencias y luego, el proceso de Python NO se quede pegado:
$ npx hyperframes init youtube_intro --non-interactive --example product-promo
$ npx hyperframes preview
$ npx hyperframes render --output test.mp4Conclusiones
Lo bueno de este método, es que, una vez generada la plantilla reutilizable, NO es necesario gastar ni un solo token para poder reutilizarla y con esto, poder tener consistencia al momento de generar estos videos que luego puedes reutilizar en donde quieras, en mi caso, las uso para presentar títulos al momento de entrar en otro apartado de mis tutoriales.
Construcción de un Sistema RAG Local para Consultar Libros Técnicos
Un sistema RAG (Retrieval-Augmented Generation) es una técnica de inteligencia artificial que permite mejorar la calidad y precisión de las respuestas de un modelo de lenguaje (LLM). Su propósito principal es consultar una base de conocimientos específica —como documentación técnica, libros o archivos corporativos— para alimentar al modelo con contexto relevante en tiempo real. Esta arquitectura es ideal para trabajar con modelos de lenguaje locales, permitiendo extraer información precisa mediante una búsqueda semántica estructurada.
Dependencias Necesarias
Para implementar este flujo de trabajo en Python utilizando PostgreSQL con la extensión pgvector, SQLAlchemy y Ollama, es necesario instalar las siguientes dependencias:
$ pip install ollama sqlalchemy pgvector langchain-text-splitters markdownFase 1: Conversión de HTML a Markdown y Generación de Metadatos
El primer paso consiste en transformar la fuente de datos —en este caso, capítulos almacenados en formato HTML dentro de una base de datos PostgreSQL— a archivos planos en formato Markdown (.md). Los archivos de marcado son interpretados de manera óptima por los LLM gracias a su estructura jerárquica basada en encabezados.
Durante este proceso, se inyecta un encabezado YAML Frontmatter en cada archivo generado. Esto permite conservar metadatos clave (como el identificador en la base de datos, el ID del libro y el título del capítulo) que posteriormente heredará el sistema RAG.
import os
import re
from markdownify import markdown as md
from sqlalchemy import text
from app.helpers.globals import ruta_base_libros_obsidian, get_engine
def migrar_html_a_obsidian(book_id: int):
with get_engine().connect() as connection:
query_libro = text("SELECT url_clean FROM books WHERE id = :book_id AND posted = 'yes'")
libro = connection.execute(query_libro, {"book_id": book_id}).fetchone()
if not libro:
print(f"❌ No se encontró ningún libro activo con el ID: {book_id}")
return
libro_folder = libro.url_clean
ruta_obsidian = os.path.join(ruta_base_libros_obsidian, libro_folder)
os.makedirs(ruta_obsidian, exist_ok=True)
query_secciones = text("""
SELECT id, title, content, orden
FROM book_sections
WHERE book_id = :book_id AND posted = 'yes'
ORDER BY orden ASC
""")
capitulos = connection.execute(query_secciones, {"book_id": book_id}).fetchall()
if not capitulos:
print(f"⚠️ El libro '{libro_folder}' no tiene secciones activas para migrar.")
return
print(f" Iniciando migración de '{libro_folder}' hacia: {ruta_obsidian}\n")
for cap in capitulos:
contenido_md = md(cap.content, heading_style="ATX")
contenido_md = re.sub(r'\n{3,}', '\n\n', contenido_md)
titulo_seguro = "".join([c if c.isalnum() or c in " _-" else "" for c in cap.title.lower()]).replace(" ", "_")
nombre_archivo = f"{str(cap.orden).zfill(2)}_{titulo_seguro}.md"
ruta_final = os.path.join(ruta_obsidian, nombre_archivo)
frontmatter = (
"---\n"
f"id_db: {cap.id}\n"
f"book_id: {book_id}\n"
f"titulo: \"{cap.title}\"\n"
"tipo: capitulo_libro\n"
"---\n\n"
)
with open(ruta_final, "w", encoding="utf-8") as f:
f.write(frontmatter + contenido_md)
print(f" ✅ Migrado: {nombre_archivo}")
print(f"\n ¡Proceso terminado! El libro con ID {book_id} está listo.")
Fase 2: Fragmentación de Documentos (Chunking) y Vectorización
Un capítulo completo puede superar la ventana de contexto de un modelo local o fragmentar la atención del sistema de búsqueda. Por ello, el texto debe dividirse en trozos o chunks. El parámetro chunk_overlap (solapamiento) resulta fundamental en este proceso: al conservar una porción del fragmento anterior en el siguiente, se evita la pérdida de contexto o la división abrupta de conceptos técnicos entre límites de fragmentos adyacentes.
Para realizar esta división respetando la estructura del contenido se utiliza RecursiveCharacterTextSplitter de LangChain. Posteriormente, cada fragmento se procesa mediante Ollama utilizando el modelo nomic-embed-text para generar un vector de 768 dimensiones.
import os
import re
from typing import Any
import ollama
from langchain_text_splitters import RecursiveCharacterTextSplitter
from app.helpers.globals import ruta_base_libros_obsidian
def procesar_boveda_a_chunks(book_folder_name: str) -> list[dict[str, Any]]:
ruta_libro = os.path.join(ruta_base_libros_obsidian, book_folder_name)
if not os.path.exists(ruta_libro):
print(f"❌ La ruta no existe: {ruta_libro}")
return []
# Configuración del divisor (~800 tokens con ~120 tokens de solapamiento)
chunk_size = 3200
chunk_overlap = 480
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separators=["\n## ", "\n### ", "\n\n", "\n", " ", ""]
)
chunks_totales = []
archivos = sorted([f for f in os.listdir(ruta_libro) if f.endswith(".md")])
print(f" Procesando {len(archivos)} capítulos de '{book_folder_name}'...")
for archivo in archivos:
ruta_archivo = os.path.join(ruta_libro, archivo)
with open(ruta_archivo, encoding="utf-8") as f:
contenido_completo = f.read()
match = re.match(r"^---\n(.*?)\n---\n(.*)", contenido_completo, re.DOTALL)
metadata_extraida = {}
cuerpo_texto = contenido_completo
if match:
frontmatter_raw = match.group(1)
cuerpo_texto = match.group(2).strip()
for linea in frontmatter_raw.split("\n"):
if ":" in linea:
k, v = linea.split(":", 1)
metadata_extraida[k.strip()] = v.strip().strip('"')
fragmentos_capitulo = splitter.split_text(cuerpo_texto)
for i, frag in enumerate(fragmentos_capitulo):
chunk_metadata = {
"source": archivo,
"capitulo_db_id": metadata_extraida.get("id_db"),
"book_id": metadata_extraida.get("book_id"),
"capitulo_titulo": metadata_extraida.get("titulo"),
"chunk_index": i,
"total_chunks_capitulo": len(fragmentos_capitulo)
}
chunks_totales.append({
"text": frag,
"metadata": chunk_metadata
})
print(f" ¡Fragmentación completada! Se generaron {len(chunks_totales)} chunks.")
return chunks_totales
def vectorizar_chunks_del_libro(book_folder_name: str) -> list[dict[str, Any]]:
chunks_planos = procesar_boveda_a_chunks(book_folder_name)
if not chunks_planos:
return []
print(" Generando embeddings locales con Ollama (nomic-embed-text)...")
chunks_vectorizados = []
for i, chunk in enumerate(chunks_planos):
texto_plano = chunk["text"]
response = ollama.embeddings(
model="nomic-embed-text",
prompt=texto_plano
)
vector = response["embedding"]
chunk_completo = {
"id": f"{chunk['metadata']['book_id']}_{chunk['metadata']['capitulo_db_id']}_{i}",
"vector": vector,
"text": texto_plano,
"metadata": chunk["metadata"]
}
chunks_vectorizados.append(chunk_completo)
print(" ¡Proceso terminado! Los trozos de Markdown se han transformado en vectores.")
return chunks_vectorizados
Fase 3: Almacenamiento Vectorial en PostgreSQL con pgvector y SQLAlchemy
Una vez obtenidos los vectores y sus metadatos, se registran en una base de datos PostgreSQL adaptada con el tipo de dato Vector mediante la extensión pgvector. En SQLAlchemy 2.0, la columna vectorial se mapea directamente definiendo la dimensión exacta producida por el modelo de embeddings (768 para nomic-embed-text).
import sys
from pgvector.sqlalchemy import Vector
from sqlalchemy import Column, Integer, String, Text, create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from app.helpers.chunker import vectorizar_chunks_del_libro
from app.helpers.globals import DATABASE_URL_PSQL
Base = declarative_base()
class DocumentChunk(Base):
__tablename__ = 'document_chunks'
id = Column(String, primary_key=True)
book_id = Column(Integer, nullable=False, index=True)
capitulo_db_id = Column(Integer, nullable=False)
capitulo_titulo = Column(String, nullable=False)
source_file = Column(String, nullable=False)
chunk_index = Column(Integer, nullable=False)
content = Column(Text, nullable=False)
embedding = Column(Vector(768), nullable=False)
def poblar_base_de_datos_vectorial(folder_name: str):
engine = create_engine(DATABASE_URL_PSQL)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
try:
chunks_vectorizados = vectorizar_chunks_del_libro(folder_name)
if not chunks_vectorizados:
print("⚠️ No se encontraron chunks para procesar.")
return
print(f"\n Insertando {len(chunks_vectorizados)} vectores en PostgreSQL...")
for c in chunks_vectorizados:
existe = session.query(DocumentChunk).filter_by(id=c["id"]).first()
if existe:
continue
nuevo_chunk = DocumentChunk(
id=c["id"],
book_id=int(c["metadata"]["book_id"]),
capitulo_db_id=int(c["metadata"]["capitulo_db_id"]),
capitulo_titulo=c["metadata"]["capitulo_titulo"],
source_file=c["metadata"]["source"],
chunk_index=c["metadata"]["chunk_index"],
content=c["text"],
embedding=c["vector"]
)
session.add(nuevo_chunk)
session.commit()
print(" ¡Base de datos vectorial poblada con éxito!")
except Exception as e:
session.rollback()
print(f"❌ Error durante la inserción en Postgres: {e}")
finally:
session.close()
if __name__ == "__main__":
if len(sys.argv) < 2:
print("❌ Error: Debes pasar el nombre de la carpeta del libro.")
sys.exit(1)
poblar_base_de_datos_vectorial(sys.argv[1])
Para ejecutar este script de poblamiento como un módulo desde la raíz del proyecto, se utiliza el siguiente comando:
python -m app.helpers.vector_seeder componentes-y-animaciones-personalizadas-en-flutterFase 4: Consulta Semántica y Búsqueda por Distancia de Coseno
Para recuperar los fragmentos más relevantes ante una consulta del usuario, la consulta de texto plano se convierte a un vector utilizando la misma API de Ollama. A continuación, se ejecuta una búsqueda por similitud en PostgreSQL haciendo uso del operador nativo de distancia del coseno (cosine_distance) en SQLAlchemy.
import ollama
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.helpers.globals import DATABASE_URL_PSQL
from app.helpers.vector_seeder import DocumentChunk
def buscar_fragmentos_relevantes(pregunta_usuario: str, limite: int = 3):
engine = create_engine(DATABASE_URL_PSQL)
Session = sessionmaker(bind=engine)
session = Session()
try:
# Generar el embedding de la consulta del usuario
response = ollama.embeddings(
model="nomic-embed-text",
prompt=pregunta_usuario
)
vector_pregunta = response["embedding"]
# Búsqueda semántica ordenada por similitud de coseno
stmt = (
select(DocumentChunk)
.order_by(DocumentChunk.embedding.cosine_distance(vector_pregunta))
.limit(limite)
)
resultados = session.scalars(stmt).all()
return resultados
except Exception as e:
print(f"❌ Error en la búsqueda semántica: {e}")
return []
finally:
session.close()
Diferencia entre RAG para Consulta y Flujos de Generación Automatizada
Es importante diferenciar la arquitectura RAG convencional de otros flujos orientados a la generación de contenido. Un RAG estricto indexa y vectoriza información para actuar como un motor de búsqueda semántica en tiempo real. En contraste, cuando el objetivo es procesar la totalidad de un libro para generar materiales derivados (como baterías de preguntas y respuestas), no es estrictamente necesario almacenar vectores en una base de datos. En esos casos, basta con segmentar el texto e iterar secuencialmente sobre los fragmentos mediante agentes o scripts de automatización.
La combinación de PostgreSQL, pgvector y Ollama permite desplegar un sistema RAG completamente local, privado y sin costos de API externas. Esta arquitectura garantiza búsquedas semánticas precisas sobre documentación técnica y libros, sirviendo como la base ideal para alimentar modelos de lenguaje con contexto propio.
Desarrollo de un Editor de Video Local impulsado por Inteligencia Artificial
Quiero presentarte un desarrollo propio: un editor de video enfocado en necesidades muy puntuales dentro de mi flujo de trabajo. Todo el sistema se ejecuta en local y de forma gratuita mediante el uso de modelos de código abierto, como Whisper para la transcripción y modelos lingüísticos locales (configurados por defecto en Gemma 4 12B).
Esta herramienta forma parte de un proyecto personal más amplio denominado Cerebro Digital, diseñado para coordinar asistentes, ejecutar agentes de automatización y centralizar tareas de creación de contenido, gestión de libros y desarrollo de cursos.
1. Flujo de Trabajo y Eliminación Automatizada de Silencios
Al grabar contenido técnico como es mi caso de programación, es común generar pausas muertas al cambiar entre pantallas o estructurar las ideas. La función principal de esta aplicación es detectar y eliminar automáticamente esos vacíos para agilizar la edición.
El proceso combina dos herramientas clave:
- FFmpeg: Analiza la onda de audio, detecta intervalos de silencio según un umbral de sensibilidad configurable y ejecuta las operaciones de corte.
- Whisper: Procesa el audio para generar la transcripción del contenido con marcas de tiempo exactas (inicio y fin de cada segmento).
Al integrar ambos análisis, el sistema remueve tanto los silencios prolongados como las muletillas, reduciendo drásticamente la duración total del video sin intervención manual.
2. Generación Automatizada de Contenido y Metadatos
A partir de la transcripción procesada, la herramienta interactúa con un modelo de lenguaje local (vía Ollama) para generar automáticamente:
- Títulos y Descripciones: Propuestas optimizadas para la publicación.
- Marcas de Tiempo (Capítulos): Marcadores de posición para la leyenda del video.
- Artículos en HTML: Publicaciones estructuradas para blogs o libros basadas en el contenido del video.
3. Adaptación a Formato Vertical (Shorts / Reels)
El sistema incluye un módulo para transformar videos horizontales en piezas verticales de corta duración (optimizadas a menos de 3 minutos):
- Redimensiona y reescala el video principal para ajustarse a la relación de aspecto vertical.
- Duplica la capa de video en el fondo, aplicando desenfoque y oscurecimiento para mejorar la estética visual.
- Ajusta la velocidad de reproducción de manera calculada si el fragmento excede el límite de tiempo permitido.
4. Subtitulado Automático y Animaciones con Manim
Aprovechando los segmentos de tiempo generados por Whisper, la aplicación permite incrustar subtítulos con posiciones y estilos personalizados, o exportar el archivo estándar en formato .srt para su edición manual.
Adicionalmente, se integró un módulo apoyado en la librería Manim de Python, que permite renderizar animaciones y esquemas explicativos en formato de código (como tablas comparativas de dos columnas) en cuestión de segundos, evitando el uso de editores de video convencionales para gráficos simples.
Conclusión
Este editor busca reducir el tiempo dedicado a tareas repetitivas en la postproducción de video, procesando todo el contenido de forma privada y eficiente directamente en la máquina local.