Digital Brain with Python and Agent Orchestration with CrewAI

- Andrés Cruz - ES En español

Video thumbnail

In this post, we will analyze the design and development process of a "digital brain", a system conceived to act as an advanced assistant in automated content generation, specifically focused on creating structural presentations. This project should be approached under the philosophy of an evolutionary development log, which implies that the architecture goes through multiple experimental phases and continuous refactorings before consolidating an optimal production version.

Environment Philosophy: Privacy and Local Execution

The pillar of this digital brain is 100% local execution. Prioritizing that Large Language Models (LLM) run locally eliminates reliance on third-party APIs, guarantees absolute privacy of the processed data, and suppresses variable operational costs associated with external token consumption.

For the initial testing phase, the Hermes framework was integrated via a conversational chat interface to evaluate the viability of the generated code. However, to mitigate syntactic noise and the rigidity of relying on external tools, the flow evolved into a fully customized backend implementation using the Python ecosystem.

Backend Structure with FastAPI

Python is the standard language in the Artificial Intelligence ecosystem, which underpins its choice for this project. FastAPI was selected as the web development framework due to its high performance, native support for asynchronous operations, and its ability to structure modular API-based services cleanly and scalably.

The backend architecture is organized under the following directory and responsibility structure:

  • Core / Models: Definition of data structures and base system configurations.
  • Agents: Modules responsible for Artificial Intelligence agent logic.
  • API / Endpoints: Exposed routes (such as the /generate endpoint) to connect the user interface with internal services.
brain-app/
├── app/
│   ├── __init__.py
│   ├── main.py            # FastAPI entry point
│   ├── core/              # Configuration and connection to Ollama/APIs
│   │   └── config.py
│   ├── agents/            # CrewAI scripts live here (JSON, HTML, Flux)
│   │   └── __init__.py
│   ├── tools/             # Custom Python skills (file saving, etc.)
│   │   └── __init__.py
│   ├── templates/         # HTML views (Dashboard Frontend)
│   │   └── index.html
│   └── static/            # Static CSS and JS
│       ├── css/
│       └── js/
├── requirements.txt       # Dependencies (fastapi, uvicorn, jinja2, crewai)
└── .env                   # Environment variables (Ollama URLs, Flux, etc.)

Agent Orchestration with CrewAI

To coordinate the behavior of local models, CrewAI was implemented, a framework specifically designed for artificial intelligence agent orchestration. CrewAI allows defining roles, assigning tools (skills), and chaining tasks sequentially.

In the first experimental design, generation was split between two sequential agents using a model from the Llama family executed locally:

  1. Content Agent: Receives the topic from the user (for example, "Routing systems in Laravel"), analyzes the subject, and generates structured information in a data interchange format (JSON).
  2. Layout Agent: Takes the JSON produced by the previous agent and handles structuring the final HTML and CSS code of the presentation, applying specific styles and color palettes.

To avoid saturating the model's context, the flow was configured in sequential and detailed mode (verbose=True) to audit agent behavior directly in the server console.

The agents with Crew:

app/agents/slides_crew.py

from crewai import LLM, Agent, Crew, Process, Task

# =====================================================================
# 1. DIRECT CONNECTION TO YOUR LOCAL OLLAMA (127.0.0.1)
# =====================================================================
# Llama 3 8B for logical processing and JSON structuring
llm_json = LLM(
    # model="ollama/llama3-default",
    model="ollama/gemma3:12b",
    base_url="http://127.0.0.1:11434"
)

# Gemma 4 12B for premium visual UI/UX layout in HTML
llm_html = LLM(
    # model="ollama/gemma4-slides",
    model="ollama/gemma3:12b",
    base_url="http://127.0.0.1:11434"
)

# =====================================================================
# 2. DEFINITION OF ATOMIC AGENTS (Without inherited tools)
# =====================================================================
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. ORCHESTRATION FUNCTION FOR YOUR BACKEND
# =====================================================================
def ejecutar_pipeline_slides(tema: str, ruta_salida: str = "app/static/presentacion.html") -> str:
    """
    Function that receives a topic, executes the local agent pipeline,
    and saves the final result in your FastAPI app static folder.
    """

    # Task 1: Generate interchange format (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
    )

    # Task 2: Consume JSON and render interactive frontend
    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 strictly:\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 () 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  # Writes file directly to your FastAPI static directory
    )

    # Brain that sequences the process
    crew = Crew(
        agents=[arquitecto_json, disenador_html],
        tasks=[tarea_json, tarea_html],
        process=Process.sequential, # Strict sequential: Task 2 depends on Task 1
        verbose=True
    )

    # Execute passing dynamic parameter
    #crew.kickoff_async(inputs={'tema': tema})
    crew.kickoff(inputs={'tema': tema})
    return ruta_salida</code></pre>
<p>
    The form:
</p>
<p>
    app/api/slides.py
</p>
<pre><code class="language-plaintext">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 e</code></pre>
<p>
    And the view presentation with GET:
</p>
<p>
    app/routers/pages.py
</p>
<pre><code class="language-plaintext">@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"},
    )</code></pre>
<p>
    Its template:
</p>
<pre><code class="language-plaintext">{% 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...";
            });
        }
    });

{% endblock %}

The Problem of Varying Structure and Non-Determinism

The first approach we made previously using dual agents revealed a critical limitation linked to the nature of LLMs. Artificial Intelligence is not deterministic, but probabilistic. This means that, given the exact same prompt, the second agent produced inconsistent, variable HTML structures prone to rendering errors.

Additionally, local model responses often include extra text delimiters or malformed code blocks, breaking strict JSON parsing and introducing instability into the main execution thread, especially if the request is processed synchronously without delegating flow control to client-side JavaScript.

Architecture Refactoring: Towards a Hybrid Approach

To resolve structural and aesthetic inconsistency (HTML and CSS), the flow was redesigned by completely removing the second agent. Instead, a hybrid approach was implemented that combines AI's analytical capability with the rigidity of traditional programmatic development.

The New Operational Workflow

The current system operates under an optimized two-step process that introduces an intermediate human validation layer:

  1. Step 1: Structured Extraction (AI): A single CrewAI agent processes the topic and returns exclusively a clean JSON schema containing slide text.
  2. Step 2: Editing & Template Injection (Programmatic): The FastAPI backend receives this JSON and displays it in an interactive web form. The user can audit, correct, or expand text directly (for instance, changing the title to "Routes in Laravel 13"). Once approved, Python takes this corrected data and injects it via structured code inside a fixed, predefined HTML template.

app/agents/slides_crew.py

# =====================================================================
# PHASE 1: Generate clean JSON proposal
# =====================================================================
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,
    )

    # Minimal single-agent crew for Phase 1
    crew_fase1 = Crew(agents=[arquitecto_json], tasks=[tarea_json], verbose=True)
    resultado = crew_fase1.kickoff(inputs={"tema": tema})

    # Clean raw LLM response before parsing
    return parsear_json_ia(str(resultado))

To clean the 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)

Now, we have two form processes: one to prompt for the topic and another to return the JSON with the ability for the user to update it directly to fit their exact needs:

@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. Convert edited textarea string into a real Python dictionary
        datos_dict = json.loads(json_editado)
        
        # 2. Load separate physical template and inject JSON data
        template_slides = templates.get_template("agents/slides/_structure.html")
        html_compilado = template_slides.render(request=request, **datos_dict)
        
        # 3. Save final result to static folder
        ruta_salida = "app/static/presentacion.html"
        with open(ruta_salida, "w", encoding="utf-8") as f:
            f.write(html_compilado)
        
        # Direct redirect to freshly baked static file
        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 e

The template used by both steps:

{% extends "base.html" %}

{% block content %}


    
        1. Define the Topic
        
        
    

    {% if json_borrador %}
    
    

    
        
            2. Review & Edit Structure
            Draft Ready
        

        You can edit the JSON directly below. When ready, click to generate the final HTML presentation.

        

        
            

            
        
    
    {% endif %}




    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";
            }
        });
    });

{% endblock %}

As you can see in step two:

templates.get_template("agents/slides/_structure.html")

We use a structure to programmatically take the JSON and IMMEDIATELY generate the presentation, saving ourselves the use of an agent and its associated overhead, so that we ALWAYS get the exact same structure for the presentation:

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

Architectural Takeaway

Not all software needs to be solved using Artificial Intelligence. Processes that demand consistency, predictability, visual layout, and animation control should be solved programmatically using templates and traditional code. Artificial Intelligence should be strictly reserved for creative tasks, information extraction, and natural language processing.

Hyperframes for Video Generation + FastAPI

Video thumbnail

In this section, we will analyze how to automate video production using web templates through programmatic rendering tools with Hyperframes integrated into a FastAPI backend. 

The main advantage of this approach lies in visual consistency: while generating animations from scratch with AI often produces unwanted variations in fonts and styles, using structured templates guarantees uniform design in every execution.

This architecture is part of an automation ecosystem ("digital brain") focused on generating multimedia assets for technical content and social media.

FastAPI Service Architecture

The backend service is structured around three essential components within the project:

  • API Routes (Endpoints): HTTP entry points responsible for receiving user parameters (text, color palettes, and template selection).
  • Template Manager: HTML/CSS files and animation components containing dynamic rendering variables.
  • Agents and Command Execution: Modules responsible for processing logic and executing system calls to compile the final video.

GET View to Select Hyperframes Template and Customize Colors and Text

First, we have an endpoint with its view to select the template:

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; });

The hyperframes templates list is retrieved from another endpoint, which is the one consumed in the previous fetch:

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())

And the web request:

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},
    )

Upon consuming the main endpoint (for example, POST /render), the application validates the data entered from the interface, replaces the dynamic placeholders inside the selected template, and processes the output into an MP4 video file:

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 e

Dynamic Template Processing and Parameter Substitution

Unlike flows based purely on generative AI, this methodology uses fixed template files that include placeholders for the variables you wish to customize:

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")

By keeping the design logic inside the template and isolating only the input data, unnecessary token consumption in recurring AI calls is eliminated. Once the base structure is designed, the entire compilation process runs locally and free of charge on the server.

Command-Line Compilation and Rendering

The core of the rendering consists of invoking the video generation CLI tool directly from Python by executing system processes. The following is the standard sequence to initialize and compile a project from the backend:

# Creating and initializing the video project from the console
npx hyperframes create mi-plantilla --template minimalist

# Executing the render from Python passing the processed template
import subprocess

command = ["npx", "hyperframes", "render", "path/to/template.html", "--output", "output/video.mp4"]
subprocess.run(command, check=True)

Before integrating the call into the FastAPI code, it is essential to test the compilation manually in the terminal to ensure that all render engine dependencies (such as headless browser instances or animation packages) have been downloaded and installed properly.

The method to select the template, change colors and texts, and generate the video using hyperframes that we call in the POST looks like this:

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]:
    """Renders a HyperFrames video and returns the job info.

    Extracted so that the router agent can call it directly.
    """
    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"Template '{template_name}' not found.")

    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 rendering video: {e.stderr}") from e

    finally:
        if work_dir.exists():
            shutil.rmtree(work_dir)

AI-Assisted Template Creation

To build the initial structure of animation templates without manually writing all CSS rotation, opacity, or scaling properties, you can use code assistants (such as those available in local-first tools or OpenCode) via a well-structured prompt generated with Gemini (as always, you can use an AI chat assistant to generate these prompts and handle implementation details):

Create an HTML/CSS/JS template for Hyperframes that animates dynamic text in a hyper-emphatic way (words/letters by block) over a high-contrast background.

Technical and visual specifications:

   Animation Mechanics:

       Receives dynamic text in a {{TITLE_TEXT}} variable.

       Automatically splits the text and displays only 2 letters at a time (or groups of 2-3 characters) sequentially in the center of the screen in giant size (min. 120px or 12vw), simulating a "Word-by-Word / Kinetic Typography" impact effect.

       The animation must be handled with GSAP on the global main timeline window.__timelines['main'].

   Architecture for Headless Rendering (Puppeteer/Seek):

       Forbidden: Do not use tl.call() with imperative DOM concatenation, nor native CSS animations (@keyframes).

       Required: The HTML must parse and pre-inject all letter pairs as independent <span> elements within the DOM, handling visibility/appearance through pure interpolation of interpolable CSS properties (opacity: 0 to opacity: 1, scale, or display/visibility mapped deterministically). Each letter pair must have its state calculable with seek(t).

   Style, Background, and Contrast:

       Includes configurable variables for background color {{BG_COLOR}} and text color {{TEXT_COLOR}}.

       Implements a CSS/JS rule to guarantee high contrast (e.g., dark background #0f0f11 with bright text #ffffff or electric yellow #ffe600, or a solid backdrop container behind each pair of letters).

       Bold/heavy sans-serif typography (such as Montserrat Black, Impact, or Inter ExtraBold) in uppercase.

   Deliverable:

       Complete single-file code with HTML structure, embedded CSS styles, and GSAP script ready to be processed frame by frame.

Once the base template is generated, it is stored in the project's static directory (app/static/templates) to be reused infinitely from the application control panel.

Before testing the implementation, you need to install hyperframes:

$ npm install -g hyperframes

And generate a video via the terminal so it downloads dependencies, ensuring the Python process does NOT get stuck afterward:

$ npx hyperframes init youtube_intro --non-interactive --example product-promo
$ npx hyperframes preview
$ npx hyperframes render --output test.mp4

Conclusions

The great thing about this method is that, once the reusable template is created, you DO NOT need to spend a single token to reuse it. This allows you to maintain consistency when generating these videos, which you can later reuse wherever you want—in my case, I use them to present titles when transitioning into another section of my tutorials.

Learn to build a digital brain with Python, FastAPI, and CrewAI. Discover how to orchestrate local AI agents with Llama and optimize your architecture by combining probabilistic models with deterministic programmatic templates.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.