Í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
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.