Compila e implementa un quiosco de IA de Fórmula E con Gemini Omni y Cloud Run

1. Introducción

Qué compilará

El quiosco de IA de la Fórmula E es una experiencia interactiva potenciada por la plataforma de Gemini y la API de Google GenAI Interactions. Los usuarios se toman una foto, eligen un escenario de la Fórmula E (como celebrar en el podio o salir de la calle de boxes) y dejan que Gemini los transforme en pilotos de carreras de la Fórmula E junto a un auto de carreras eléctrico Gen4.

diagrama de solución para el lab de kiosco

Enfoque de aprendizaje interactivo

En este codelab, escribirás la lógica principal de IA generativa con la API de Interactions en la plataforma de Gemini (enterprise=True). Completarás las brechas de código clave para lo siguiente:

  1. Inicialización del cliente: Inicializa genai.Client(enterprise=True) para la plataforma de Gemini.
  2. Gemini 3.8 Flash: Genera una instrucción enriquecida a partir de la foto del usuario con una entrada multimodal basada en diccionarios ([{"type": "text", ...}, {"type": "image", ...}]) y generation_config={"thinking_level": "high", "temperature": 0.2}.
  3. Nano Banana 2/2.1 (gemini-3.1-flash-image or gemini-nano-banana-2.1): Avatar del conductor de imagen a imagen y creación de diseño de Gen4 con la instrucción expandida.
  4. Gemini Omni 1.1 Flash (gemini-omni-1.1-flash-preview): Generación de video asíncrona con background=True y sondeo client.interactions.get().

No te preocupes por quedarte sin ideas Cada ejercicio de programación incluye un bloque 💡 Solución y explicación. Si tienes problemas, desplázate para ver el código completo y los detalles de los parámetros.

Los pasos de configuración que no son de IA (estructura de shell, parámetros de configuración de Docker y la implementación de Cloud Run) siguen siendo comandos optimizados para copiar y pegar, de modo que puedas enfocarte en dominar la API de Interactions.

Recursos y desarrollo de la marca principales

Recurso

Descripción

URL de origen

Auto de Fórmula E de 4ª generación

Referencia oficial del auto de carreras completamente eléctrico de 4ª gen.

https://coffeecornermotorsport.com/wp-content/uploads/2026/04/m37258_GEN4-HERO3.webp

Logotipo de Formula E y Google Cloud

Logotipo transparente con asociación de marcas

https://storage.googleapis.com/fe-demo-branding/formula_e_gc_logo_transparent.webp

Fuente Google Sans Regular

Tipografía oficial de la marca

https://storage.googleapis.com/fe-demo-branding/GoogleSans-Regular.ttf

Fuente Google Sans Medium

Tipografía oficial de la marca

https://storage.googleapis.com/fe-demo-branding/GoogleSans-Medium.ttf

2. 🛠️ Configuración del entorno y estructura del proyecto

Crearás el directorio del espacio de trabajo y los archivos del proyecto directamente en Cloud Shell con comandos de la CLI modulares.

1. Abre Cloud Shell y crea el directorio del proyecto

👉 Abre el editor de Cloud Shell en tu navegador.

👉💻 Crea la estructura del espacio de trabajo:

mkdir -p ~/fe-genmedia-kiosk/{backend/app/{routers,services,prompts},frontend/{public/fonts,src},scripts}
cd ~/fe-genmedia-kiosk

2. Habilita las APIs de Google Cloud requeridas

👉💻 Establece el ID del proyecto actual y habilita los servicios necesarios:

export PROJECT_ID=$(gcloud config get-value project)
echo "Using Project ID: $PROJECT_ID"

gcloud services enable \
    run.googleapis.com \
    cloudbuild.googleapis.com \
    aiplatform.googleapis.com \
    storage.googleapis.com \
    artifactregistry.googleapis.com \
    compute.googleapis.com

3. Crea una cuenta de servicio y configura los permisos de IAM

Ten en cuenta que, si usas una cuenta de GCP de organización y no puedes generar una cuenta de servicio, tendrás una cuenta de servicio de Compute Engine predeterminada con permisos preconfigurados.

👉💻 Ejecuta los comandos de configuración para la cuenta de servicio del entorno de ejecución y Cloud Build:

# 1. Configure Cloud Build permissions for default Compute Service Account
# (Cloud Run source deployments use Cloud Build and the default Compute Engine SA)
export PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format="value(projectNumber)")

for role in roles/storage.admin roles/logging.logWriter roles/artifactregistry.writer; do
  gcloud projects add-iam-policy-binding $PROJECT_ID \
    --member="serviceAccount:${PROJECT_NUMBER}-compute@developer.gserviceaccount.com" \
    --role="$role"
done

# 2. Create Cloud Storage Bucket
export BUCKET_NAME="fe-genmedia-kiosk-${PROJECT_ID}"
gcloud storage buckets create gs://${BUCKET_NAME} --location=us-central1

# 3. Configure CORS for web browser uploads
cat << 'EOF' > cors.json
[
  {
    "origin": ["*"],
    "method": ["GET", "POST", "PUT", "DELETE", "HEAD"],
    "responseHeader": ["*"],
    "maxAgeSeconds": 3600
  }
]
EOF
gcloud storage buckets update gs://${BUCKET_NAME} --cors-file=cors.json

4. Descarga los activos de la marca de la Fórmula E

👉💻 Recupera los recursos de marca compartida en tu carpeta pública:

cd ~/fe-genmedia-kiosk/frontend/public

# Download Co-Branded Logo
curl -o formula_e_gc_logo.webp "https://storage.googleapis.com/fe-demo-branding/formula_e_gc_logo_transparent.webp"

# Download Gen4 Reference Car Image
curl -o gen4_car_reference.jpg "https://resources.formula-e.pulselive.com/photo-resources/2026/04/21/7c76418b-85af-492c-8ba6-81dabd6f3b4c/JAZ_3586-FE-2026-Gen4-Unleashed-JazzVian-for-ParcFerme.jpg?width=1280"

# Download Typography
curl -o fonts/GoogleSans-Regular.ttf "https://storage.googleapis.com/fe-demo-branding/GoogleSans-Regular.ttf"
curl -o fonts/GoogleSans-Medium.ttf "https://storage.googleapis.com/fe-demo-branding/GoogleSans-Medium.ttf"

cd ~/fe-genmedia-kiosk

5. Crea la configuración del entorno y las dependencias

👉💻 Crea .env y backend/pyproject.toml:

cat << EOF > .env
GOOGLE_CLOUD_PROJECT=${PROJECT_ID}
GOOGLE_CLOUD_LOCATION=global
GCS_BUCKET=${BUCKET_NAME}
GCS_SIGNING_SA=fe-kiosk-sa@${PROJECT_ID}.iam.gserviceaccount.com
EOF

cat << 'EOF' > backend/pyproject.toml
[project]
name = "fe-genmedia-backend"
version = "1.0.0"
description = "Formula E Generative Media Kiosk Backend"
dependencies = [
    "fastapi>=0.110.0",
    "uvicorn[standard]>=0.28.0",
    "google-genai>=2.22.0",
    "google-cloud-storage>=2.14.0",
    "pydantic>=2.6.0",
    "python-multipart>=0.0.9",
    "pillow>=10.2.0",
    "aiofiles>=23.2.0"
]
EOF

cat << 'EOF' > backend/app/config.py
import os
from pydantic import BaseModel

class Settings(BaseModel):
    project_id: str = os.getenv("GOOGLE_CLOUD_PROJECT", "")
    location: str = os.getenv("GOOGLE_CLOUD_LOCATION", "global")
    gcs_bucket: str = os.getenv("GCS_BUCKET", "")

settings = Settings()
EOF

6. Servicio de Gemini para Seed Starter (backend/app/services/gemini_service.py)

👉💻 Crea el archivo de servicio de partida que contiene el scaffolding y los espacios TODO para los desafíos prácticos, y copia y pega el código de partida:

touch backend/app/services/gemini_service.py
import base64
import json
import time
from google import genai
from google.genai import types
from google.cloud import storage
from app.config import settings

# =====================================================================
# TODO 1: Initialize the Google GenAI Client for Gemini  Platform
# Use genai.Client() with enterprise=True, project, and location from settings.
# =====================================================================
client = None  # REPLACE THIS WITH YOUR CODE

def upload_to_gcs(data_bytes: bytes, filename: str, content_type: str) -> str:
    """Uploads binary data to Cloud Storage bucket and returns public URL."""
    storage_client = storage.Client()
    bucket = storage_client.bucket(settings.gcs_bucket)
    blob = bucket.blob(filename)
    blob.upload_from_string(data_bytes, content_type=content_type)
    return f"https://storage.googleapis.com/{settings.gcs_bucket}/{filename}"

def generate_driver_prompt(image_bytes: bytes, scene_option: str) -> str:
    """Uses Gemini 3.8 Flash via Interactions API to craft a detailed prompt from the driver photo."""
    base64_image = base64.b64encode(image_bytes).decode("utf-8")
    
    # =====================================================================
    # TODO 2: Call client.interactions.create()
    # Model: "gemini-3.8-flash"
    # Input: list of dicts [{"type": "text", ...}, {"type": "image", "bytes": ..., "mime_type": "image/jpeg"}]
    # Generation Config: {"thinking_level": "high", "temperature": 0.2}
    # =====================================================================
    interaction = None  # REPLACE THIS WITH YOUR CODE
    
    return interaction.output_text

def generate_driver_avatar(driver_prompt: str, image_bytes: bytes) -> bytes:
    """Uses Nano Banana 2 (gemini-3.1-flash-image) to generate stylized driver avatar."""
    base64_image = base64.b64encode(image_bytes).decode("utf-8")
    
    # =====================================================================
    # TODO 3: Call client.interactions.create() for image generation
    # Model: "gemini-3.1-flash-image"
    # Input: list of dicts containing prompt text and input driver image
    # Generation Config: {"aspect_ratio": "16:9", "output_mime_type": "image/png"}
    # =====================================================================
    interaction = None  # REPLACE THIS WITH YOUR CODE
    
    if interaction and interaction.output_image:
        return base64.b64decode(interaction.output_image.data)
    raise ValueError("Failed to generate avatar image.")

def generate_formula_e_video(driver_prompt: str, avatar_bytes: bytes) -> bytes:
    """Uses Gemini Omni Flash to generate an 8-second cinematic Formula E video."""
    base64_avatar = base64.b64encode(avatar_bytes).decode("utf-8")
    
    # =====================================================================
    # TODO 4a: Start async video generation with 'gemini-omni-1.1-flash-preview'
    # Input: list of dicts with avatar image and prompt text. Set background=True!
    # =====================================================================
    interaction = None  # REPLACE THIS WITH YOUR CODE
    
    # =====================================================================
    # TODO 4b: Write polling loop with client.interactions.get(interaction.id)
    # Loop every 5 seconds until status == "completed" or "failed".
    # Return base64.b64decode(status_check.output_video.data)
    # =====================================================================
    while True:
        # REPLACE THIS LOOP BODY WITH YOUR CODE
        time.sleep(5)

3. ⚡ Implementa la API de Interactions y los modelos generativos

En esta sección, escribirás la lógica principal de IA generativa en backend/app/services/gemini_service.py con la API de Interactions y la API de GenerateContent en Gemini Platform.

Abre backend/app/services/gemini_service.py en el Editor de Cloud Shell. Encontrarás cuatro espacios de desafío TODO para completar.

Desafío 1: Inicializa el cliente de Gemini Enterprise

Abre backend/app/services/gemini_service.py. Completa TODO 1 inicializando el genai.Client configurado para Gemini Enterprise (enterprise=True).

import base64
import json
import time
from google import genai
from google.genai import types
from app.config import settings

# =====================================================================
# TODO 1: Initialize the Google GenAI Client for Gemini Platform
# Use genai.Client() with enterprise=True, project, and location from settings.
# =====================================================================
client = None  # REPLACE THIS WITH YOUR CODE

💡 ¿Necesitas una pista o te trabaste? Desplázate para ver la solución de TODO 1

client = genai.Client(
    enterprise=True,
    project=settings.project_id,
    location=settings.location
)

Desafío 2: Expande las instrucciones del conductor con gemini-3.8-flash

Completa TODO 2 en generate_driver_prompt(). Usa gemini-3.8-flash para analizar la foto del conductor y escribir una instrucción detallada para Nano Banana 2. Pasa una lista de diccionarios a input y pasa generation_config={"thinking_level": "high", "temperature": 0.2}.

def generate_driver_prompt(image_bytes: bytes, scene_option: str) -> str:
    """Uses Gemini 3.8 Flash via Interactions API to craft a detailed prompt from the driver photo."""
    base64_image = base64.b64encode(image_bytes).decode("utf-8")
    
    # =====================================================================
    # TODO 2: Call client.interactions.create()
    # Model: "gemini-3.8-flash"
    # Input: list of dicts [{"type": "text", ...}, {"type": "image", "bytes": ..., "mime_type": "image/jpeg"}]
    # Generation Config: {"thinking_level": "high", "temperature": 0.2}
    # =====================================================================
    interaction = None  # REPLACE THIS WITH YOUR CODE
    
    return interaction.output_text

💡 ¿Necesitas una pista o te trabaste? Haz clic para expandir la solución para TODO 2

    interaction = client.interactions.create(
        model="gemini-3.8-flash",
        input=[
            {"type": "text", "text": f"Analyze this photo and write a prompt describing this person as a Formula E race driver in a high-tech suit for a {scene_option} scene beside a Gen4 car."},
            {"type": "image", "bytes": base64_image, "mime_type": "image/jpeg"}
        ],
        generation_config={
            "thinking_level": "high",
            "temperature": 0.2
        }
    )

Desafío 3: Generación de avatares con Nano Banana 2 (gemini-3.1-flash-image a través de la API de GenerateContent)

Completa TODO 3 en generate_driver_avatar(). Llama a client.models.generate_content() con gemini-3.1-flash-image (Nano Banana 2) para renderizar el avatar estilizado del piloto de Fórmula E y el auto Gen4.

def generate_driver_avatar(driver_prompt: str, image_bytes: bytes) -> bytes:
    """Uses Nano Banana 2 (gemini-3.1-flash-image) to generate stylized driver avatar."""

    text_parts = types.Part.from_text(text=f"Generate a stylized Formula E driver portrait: {driver_prompt}")

    image_parts = types.Part.from_bytes(data=image_bytes,
        mime_type='image/jpeg',)

    gen4_image_parts = types.Part.from_uri(file_uri="https://coffeecornermotorsport.com/wp-content/uploads/2026/04/m37258_GEN4-HERO3.webp", mime_type="image/webp")
    
    # =====================================================================
    # TODO 3: Call client.models.generate_content() for image generation
    # Model: "gemini-3.1-flash-image" or "gemini-nano-banana-2.1"
    # Content: A list with the parts
    # Generation Config: Image Response Modality and 16:9 Aspect ratio
    # =====================================================================
    response = None  # REPLACE THIS WITH YOUR CODE
    
    generated_bytes = None
        if response.candidates and response.candidates[0].content.parts:
        for part in response.candidates[0].content.parts:
            if part.inline_data and part.inline_data.data:
                generated_bytes = part.inline_data.data
                break

    return base64.b64encode(generated_bytes).decode('utf-8')

💡 ¿Necesitas una pista o te trabaste? Desplázate para ver la solución de TODO 3

    
    response = client.models.generate_content(
      model="gemini-3.1-flash-image", # Or "gemini-nano-banana-2.1"
      content=[text_parts, image_parts, gen4_image_parts],
      config=types.GenerateContentConfig(
        response_modalities=["IMAGE"],
        image_config=types.ImageConfig(aspect_ratio="16:9"),
        )
      )

Desafío 4: Generación de videos con Gemini Omni 1.1 Flash

Completa TODO 4 en generate_formula_e_video(). Inicia una interacción de video asíncrona con gemini-omni-1.1-flash-preview usando background=True, sondea client.interactions.get() hasta que el estado sea "completado" y devuelve los bytes de video decodificados en base64 (output_video.data).

def generate_formula_e_video(driver_prompt: str, avatar_bytes: bytes) -> bytes:
    """Uses Gemini Omni 1.1 Flash to generate an 8-second cinematic Formula E video."""
    base64_avatar = base64.b64encode(avatar_bytes).decode("utf-8")
    
    # =====================================================================
    # TODO 4a: Start async video generation with 'gemini-omni-1.1-flash-preview'
    # Input: list of dicts with avatar image and prompt text. Set background=True!
    # =====================================================================
    interaction = None  # REPLACE THIS WITH YOUR CODE
    
    # =====================================================================
    # TODO 4b: Write polling loop with client.interactions.get(interaction.id)
    # Loop every 5 seconds until status == "completed" or "failed".
    # Return base64.b64decode(status_check.output_video.data)
    # =====================================================================
    while True:
        # REPLACE THIS LOOP BODY WITH YOUR CODE
        time.sleep(5)

💡 ¿Necesitas una pista o te trabaste? Haz clic para expandir la solución para TODO 4

    # TODO 4a Solution:
    interaction = client.interactions.create(
        model="gemini-omni-1.1-flash-preview",
        input=[
            {"type": "text", "text": f"Generate an 8-second 60fps cinematic video: {driver_prompt}. Formula E racing atmosphere."},
            {"type": "image", "bytes": base64_avatar, "mime_type": "image/png"}
        ],
        background=True
    )
    
    # TODO 4b Solution:
    while True:
        status_check = client.interactions.get(interaction.id)
        if status_check.status == "completed":
            return base64.b64decode(status_check.output_video.data)
        elif status_check.status in ["failed", "cancelled"]:
            raise RuntimeError(f"Video generation failed: {status_check.status}")
        time.sleep(5)

4. 🏗️ Ensamblado de la aplicación principal y el frontend

Ahora, crea los archivos de wrapper de la aplicación restantes, los endpoints de API y la interfaz de React.

[!IMPORTANT] Asegúrate de ejecutar todos los comandos de creación de archivos de esta sección desde el directorio raíz del espacio de trabajo (~/fe-genmedia-kiosk).

👉💻 Navega al directorio raíz del proyecto:

cd ~/fe-genmedia-kiosk

1. Aplicación principal de FastAPI (backend/app/main.py)

👉💻 Crea backend/app/main.py y copia y pega el siguiente código de Python:

touch backend/app/main.py
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import asyncio
import uuid
from app.services.gemini_service import (
    generate_driver_prompt,
    generate_driver_avatar,
    generate_formula_e_video,
    upload_to_gcs
)

app = FastAPI(title="Formula E AI Kiosk API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

jobs_db = {}

class JobStatus(BaseModel):
    job_id: str
    status: str
    stage: str
    result_url: str | None = None

@app.get("/api/health")
def health_check():
    return {"status": "ok", "experience": "Formula E Gen4 AI Kiosk"}

@app.post("/api/process-driver")
async def process_driver(
    file: UploadFile = File(...),
    option: str = Form("podium_celebration")
):
    job_id = str(uuid.uuid4())
    image_bytes = await file.read()
    
    jobs_db[job_id] = {
        "status": "processing",
        "stage": "generating_prompt",
        "result_url": None
    }
    
    asyncio.create_task(run_kiosk_pipeline(job_id, image_bytes, option))
    return {"job_id": job_id, "status": "queued"}

async def run_kiosk_pipeline(job_id: str, image_bytes: bytes, option: str):
    try:
        jobs_db[job_id]["stage"] = "Writing driver prompt with Gemini 3.8 Flash..."
        driver_prompt = generate_driver_prompt(image_bytes, option)
        
        jobs_db[job_id]["stage"] = "Rendering Nano Banana 2 avatar..."
        avatar_bytes = generate_driver_avatar(driver_prompt, image_bytes)
        
        jobs_db[job_id]["stage"] = "Generating Gemini Omni Flash video..."
        video_bytes = generate_formula_e_video(driver_prompt, avatar_bytes)
        
        jobs_db[job_id]["stage"] = "Publishing generated video to Cloud Storage..."
        result_url = upload_to_gcs(video_bytes, f"videos/{job_id}.mp4", "video/mp4")
        
        jobs_db[job_id]["status"] = "completed"
        jobs_db[job_id]["stage"] = "Done"
        jobs_db[job_id]["result_url"] = result_url
    except Exception as e:
        jobs_db[job_id]["status"] = "failed"
        jobs_db[job_id]["stage"] = str(e)

@app.get("/api/job/{job_id}", response_model=JobStatus)
def get_job_status(job_id: str):
    if job_id not in jobs_db:
        raise HTTPException(status_code=404, detail="Job not found")
    data = jobs_db[job_id]
    return JobStatus(
        job_id=job_id,
        status=data["status"],
        stage=data["stage"],
        result_url=data.get("result_url")
    )

2. Backend Dockerfile (backend/Dockerfile)

👉💻 Crea la configuración del contenedor de backend:

cat << 'EOF' > backend/Dockerfile
FROM python:3.11-slim
RUN apt-get update && apt-get install -y ffmpeg libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir .
COPY app/ ./app/
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
EOF

3. Aplicación y configuración de React de frontend

Crea la configuración del frontend y los archivos de origen paso a paso para asegurarte de que cada archivo se genere de forma limpia en Cloud Shell sin problemas de búfer de pegado.

3.1 Manifiesto del paquete (frontend/package.json)

👉💻 Crea la definición de dependencias del frontend:

cat << 'EOF' > frontend/package.json
{
  "name": "fe-genmedia-frontend",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.66",
    "@types/react-dom": "^18.2.22",
    "@vitejs/plugin-react": "^4.2.1",
    "typescript": "^5.2.2",
    "vite": "^5.1.6"
  }
}
EOF

3.2 Configuración de TypeScript (frontend/tsconfig.json)

👉💻 Crea frontend/tsconfig.json para que el compilador de TypeScript (tsc) pueda validar y compilar el proyecto:

cat << 'EOF' > frontend/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": false
  },
  "include": ["src"]
}
EOF

3.3 Configuración de compilación de Vite (frontend/vite.config.ts)

👉💻 Crea la configuración de Vite:

cat << 'EOF' > frontend/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    port: 8080,
    host: true
  }
});
EOF

3.4 Punto de entrada HTML (frontend/index.html)

👉💻 Crea el punto de entrada HTML raíz:

cat << 'EOF' > frontend/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Formula E AI Kiosk</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
EOF

3.5 Hoja de estilo de la marca (frontend/src/index.css)

👉💻 Crea el tema personalizado de modo oscuro de la Fórmula E:

cat << 'EOF' > frontend/src/index.css
@font-face {
  font-family: 'Google Sans';
  src: url('/fonts/GoogleSans-Regular.ttf') format('truetype');
  font-weight: 400;
}

@font-face {
  font-family: 'Google Sans';
  src: url('/fonts/GoogleSans-Medium.ttf') format('truetype');
  font-weight: 500;
}

body {
  margin: 0;
  font-family: 'Google Sans', sans-serif;
  background-color: #0b0e14;
  color: #ffffff;
}

.fe-container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
  text-align: center;
}

.fe-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 2rem;
  background: rgba(15, 23, 42, 0.8);
  border-bottom: 2px solid #00e5ff;
}

.fe-btn {
  background: linear-gradient(135deg, #00e5ff 0%, #0066ff 100%);
  color: #000;
  font-weight: bold;
  padding: 0.8rem 2rem;
  border: none;
  border-radius: 30px;
  cursor: pointer;
  font-size: 1.1rem;
  transition: all 0.2s ease;
}

.fe-btn:hover:not(:disabled) {
  transform: translateY(-2px);
  box-shadow: 0 4px 15px rgba(0, 229, 255, 0.4);
}

.fe-btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.fe-btn-secondary {
  background: rgba(30, 41, 59, 0.8);
  color: #00e5ff;
  border: 1px solid #00e5ff;
  font-weight: bold;
  padding: 0.6rem 1.6rem;
  border-radius: 30px;
  cursor: pointer;
  font-size: 1rem;
  transition: all 0.2s ease;
}

.fe-btn-secondary:hover:not(:disabled) {
  background: rgba(0, 229, 255, 0.15);
  box-shadow: 0 0 10px rgba(0, 229, 255, 0.3);
}

.fe-card {
  background: #151c28;
  border: 1px solid #1e293b;
  border-radius: 16px;
  padding: 2rem;
  margin: 1.5rem auto;
  max-width: 720px;
}

.fe-tabs {
  display: flex;
  justify-content: center;
  gap: 1rem;
  margin-bottom: 1.5rem;
}

.fe-tab-btn {
  background: #0f172a;
  border: 1px solid #334155;
  color: #94a3b8;
  font-size: 1rem;
  font-weight: 500;
  padding: 0.6rem 1.4rem;
  border-radius: 20px;
  cursor: pointer;
  transition: all 0.2s ease;
}

.fe-tab-btn.active {
  background: #1e293b;
  border-color: #00e5ff;
  color: #00e5ff;
  box-shadow: 0 0 10px rgba(0, 229, 255, 0.2);
}

.fe-camera-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  margin: 1.5rem auto;
}

.fe-camera-feed-box {
  position: relative;
  width: 100%;
  max-width: 480px;
  aspect-ratio: 4/3;
  background: #0b0e14;
  border-radius: 12px;
  overflow: hidden;
  border: 2px solid #334155;
  display: flex;
  align-items: center;
  justify-content: center;
}

.fe-video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scaleX(-1);
}

.fe-captured-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 12px;
}

.fe-alert {
  background: rgba(239, 68, 68, 0.15);
  border: 1px solid #ef4444;
  color: #fca5a5;
  padding: 0.8rem 1.2rem;
  border-radius: 8px;
  margin: 1rem auto;
  max-width: 480px;
  font-size: 0.95rem;
}

.fe-file-picker {
  margin: 1.5rem 0;
}

.fe-file-picker input[type="file"] {
  background: #0f172a;
  border: 1px dashed #334155;
  padding: 1.5rem;
  border-radius: 12px;
  color: #94a3b8;
  width: 80%;
  max-width: 400px;
  cursor: pointer;
}

.fe-file-picker input[type="file"]:hover {
  border-color: #00e5ff;
  color: #ffffff;
}
EOF

3.6 Componente raíz de React (frontend/src/main.tsx)

👉💻 Crea el archivo de arranque de la aplicación de React:

cat << 'EOF' > frontend/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);
EOF

3.7 IU de kiosco interactivo (frontend/src/App.tsx)

👉💻 Crea la interfaz principal interactiva del kiosco. Para ello, crea App.tsx y copia y pega el código de React:

touch frontend/src/App.tsx
import React, { useState, useRef, useEffect, useCallback } from 'react';
import './index.css';

export default function App() {
  const [inputMode, setInputMode] = useState<'camera' | 'upload'>('camera');
  const [file, setFile] = useState<File | null>(null);
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
  const [cameraActive, setCameraActive] = useState<boolean>(false);
  const [cameraError, setCameraError] = useState<string | null>(null);

  const [option, setOption] = useState('podium_celebration');
  const [jobId, setJobId] = useState<string | null>(null);
  const [status, setStatus] = useState<string>('');
  const [stage, setStage] = useState<string>('');
  const [resultUrl, setResultUrl] = useState<string | null>(null);

  const videoRef = useRef<HTMLVideoElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const streamRef = useRef<MediaStream | null>(null);

  const stopCamera = useCallback(() => {
    if (streamRef.current) {
      streamRef.current.getTracks().forEach((track) => track.stop());
      streamRef.current = null;
    }
    if (videoRef.current) {
      videoRef.current.srcObject = null;
    }
    setCameraActive(false);
  }, []);

  const startCamera = useCallback(async () => {
    stopCamera();
    setCameraError(null);
    try {
      if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
        throw new Error('Camera is not supported on this browser or device.');
      }
      const stream = await navigator.mediaDevices.getUserMedia({
        video: {
          width: { ideal: 1280 },
          height: { ideal: 720 },
          facingMode: 'user',
        },
        audio: false,
      });
      streamRef.current = stream;
      if (videoRef.current) {
        videoRef.current.srcObject = stream;
        await videoRef.current.play();
      }
      setCameraActive(true);
    } catch (err: any) {
      console.error('Error accessing camera:', err);
      setCameraError(err.message || 'Unable to access camera. Please check permissions or switch to file upload.');
      setCameraActive(false);
    }
  }, [stopCamera]);

  useEffect(() => {
    if (inputMode === 'camera' && !file && !jobId) {
      startCamera();
    } else {
      stopCamera();
    }
    return () => {
      stopCamera();
    };
  }, [inputMode, file, jobId, startCamera, stopCamera]);

  const handleCapture = () => {
    const video = videoRef.current;
    const canvas = canvasRef.current;
    if (!video || !canvas) return;

    const width = video.videoWidth || 640;
    const height = video.videoHeight || 480;

    canvas.width = width;
    canvas.height = height;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    // Flip horizontally to match selfie preview
    ctx.translate(width, 0);
    ctx.scale(-1, 1);
    ctx.drawImage(video, 0, 0, width, height);

    canvas.toBlob((blob) => {
      if (!blob) return;
      const capturedFile = new File([blob], `driver_photo_${Date.now()}.jpg`, {
        type: 'image/jpeg',
      });
      setFile(capturedFile);
      setPreviewUrl(URL.createObjectURL(blob));
      stopCamera();
    }, 'image/jpeg', 0.95);
  };

  const handleRetake = () => {
    if (previewUrl) {
      URL.revokeObjectURL(previewUrl);
    }
    setFile(null);
    setPreviewUrl(null);
    if (inputMode === 'camera') {
      startCamera();
    }
  };

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const selected = e.target.files?.[0] || null;
    if (selected) {
      setFile(selected);
      if (previewUrl) {
        URL.revokeObjectURL(previewUrl);
      }
      setPreviewUrl(URL.createObjectURL(selected));
    }
  };

  const handleModeChange = (mode: 'camera' | 'upload') => {
    if (mode === inputMode) return;
    if (previewUrl) {
      URL.revokeObjectURL(previewUrl);
    }
    setFile(null);
    setPreviewUrl(null);
    setInputMode(mode);
  };

  const handleUpload = async () => {
    if (!file) return;
    stopCamera();

    const formData = new FormData();
    formData.append('file', file);
    formData.append('option', option);

    const apiBase = (import.meta as any).env.VITE_API_BASE || '';
    const res = await fetch(`${apiBase}/api/process-driver`, { method: 'POST', body: formData });
    const data = await res.json();
    setJobId(data.job_id);
    pollJob(data.job_id);
  };

  const pollJob = (id: string) => {
    const apiBase = (import.meta as any).env.VITE_API_BASE || '';
    const interval = window.setInterval(async () => {
      try {
        const res = await fetch(`${apiBase}/api/job/${id}`);
        const data = await res.json();
        setStatus(data.status);
        setStage(data.stage);
        if (data.status === 'completed') {
          setResultUrl(data.result_url);
          clearInterval(interval);
        } else if (data.status === 'failed') {
          clearInterval(interval);
        }
      } catch (err) {
        console.error('Error polling job:', err);
      }
    }, 3000);
  };

  const handleReset = () => {
    if (previewUrl) {
      URL.revokeObjectURL(previewUrl);
    }
    setFile(null);
    setPreviewUrl(null);
    setJobId(null);
    setStatus('');
    setStage('');
    setResultUrl(null);
  };

  return (
    <div>
      <header className="fe-header">
        <img src="/formula_e_gc_logo.webp" alt="Formula E Google Cloud" height="45" />
        <h2>GEN4 AI DRIVER EXPERIENCE</h2>
      </header>

      <main className="fe-container">
        <h1>Transform into a Formula E Driver</h1>
        <p>Powered by Gemini Platform & Google Cloud Run</p>

        {!jobId ? (
          <div className="fe-card">
            <h3>Step 1: Take or Upload Your Driver Photo</h3>

            <div className="fe-tabs">
              <button
                type="button"
                className={`fe-tab-btn ${inputMode === 'camera' ? 'active' : ''}`}
                onClick={() => handleModeChange('camera')}
              >
                📷 Take Photo
              </button>
              <button
                type="button"
                className={`fe-tab-btn ${inputMode === 'upload' ? 'active' : ''}`}
                onClick={() => handleModeChange('upload')}
              >
                📁 Upload Photo
              </button>
            </div>

            {/* Mode: Camera */}
            {inputMode === 'camera' && (
              <div className="fe-camera-container">
                {cameraError && (
                  <div className="fe-alert">
                    <p>{cameraError}</p>
                    <button type="button" className="fe-btn-secondary" onClick={startCamera} style={ { marginTop: '0.5rem' } }>
                      Try Camera Again
                    </button>
                  </div>
                )}

                <div className="fe-camera-feed-box">
                  {!file ? (
                    <video
                      ref={videoRef}
                      className="fe-video"
                      autoPlay
                      playsInline
                      muted
                    />
                  ) : (
                    previewUrl && (
                      <img src={previewUrl} alt="Captured Driver" className="fe-captured-img" />
                    )
                  )}
                </div>

                <canvas ref={canvasRef} style={ { display: 'none' } } />

                <div style={ { marginTop: '1rem', display: 'flex', gap: '0.75rem', justifyContent: 'center' } }>
                  {!file ? (
                    <button
                      type="button"
                      className="fe-btn"
                      onClick={handleCapture}
                      disabled={!cameraActive}
                    >
                      📸 Snap Photo
                    </button>
                  ) : (
                    <button
                      type="button"
                      className="fe-btn-secondary"
                      onClick={handleRetake}
                    >
                      🔄 Retake Photo
                    </button>
                  )}
                </div>
              </div>
            )}

            {/* Mode: Upload */}
            {inputMode === 'upload' && (
              <div className="fe-file-picker">
                {!file ? (
                  <input
                    type="file"
                    accept="image/*"
                    onChange={handleFileChange}
                  />
                ) : (
                  <div>
                    {previewUrl && (
                      <div className="fe-camera-feed-box" style={ { margin: '0 auto 1rem auto' } }>
                        <img src={previewUrl} alt="Uploaded Driver" className="fe-captured-img" />
                      </div>
                    )}
                    <button type="button" className="fe-btn-secondary" onClick={handleRetake}>
                      Choose Different Photo
                    </button>
                  </div>
                )}
              </div>
            )}

            <h3>Step 2: Choose Your Experience</h3>
            <select
              value={option}
              onChange={(e) => setOption(e.target.value)}
              style={ { padding: '0.6rem 1rem', fontSize: '1rem', background: '#0f172a', color: '#fff', border: '1px solid #334155', borderRadius: '8px' } }
            >
              <option value="podium_celebration">🏆 Podium Winner Celebration</option>
              <option value="pit_lane_launch">⚡ Pit Lane Gen4 Launch</option>
            </select>

            <div style={ { marginTop: '2rem' } }>
              <button className="fe-btn" onClick={handleUpload} disabled={!file}>
                START GENERATIVE PIPELINE
              </button>
            </div>
          </div>
        ) : (
          <div className="fe-card">
            <h2>Pipeline Progress</h2>
            <p><strong>Status:</strong> {status.toUpperCase() || 'INITIALIZING...'}</p>
            <p><strong>Stage:</strong> {stage || 'Starting processing...'}</p>

            {resultUrl && (
              <div style={ { marginTop: '2rem' } }>
                <h3>Your Formula E Generative Video</h3>
                <video src={resultUrl} controls autoPlay loop style={ { width: '100%', maxWidth: '720px', borderRadius: '12px' } } />
              </div>
            )}

            {(status === 'completed' || status === 'failed') && (
              <div style={ { marginTop: '2rem' } }>
                <button type="button" className="fe-btn-secondary" onClick={handleReset}>
                  CREATE ANOTHER DRIVER
                </button>
              </div>
            )}
          </div>
        )}
      </main>
    </div>
  );
}

3.8 Configuración del servidor Nginx (frontend/nginx.conf)

👉💻 Crea la configuración del servidor web Nginx para el enrutamiento de la aplicación de una sola página:

cat << 'EOF' > frontend/nginx.conf
server {
    listen 8080;
    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;
    }
}
EOF

3.9 Configuración de la compilación del contenedor de frontend (frontend/Dockerfile y frontend/.dockerignore)

👉💻 Crea .dockerignore y la Dockerfile de producción de varias etapas:

cat << 'EOF' > frontend/.dockerignore
node_modules
dist
.git
EOF
touch frontend/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm install
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080

5. 🚀 Implementa en Cloud Run

1. Compila e implementa el contenedor de backend

👉💻 Implementa el servidor de backend en Cloud Run:

cd ~/fe-genmedia-kiosk/backend

gcloud run deploy fe-kiosk-backend \
  --source . \
  --region us-central1 \
  --allow-unauthenticated \
  --port 8080

2. Compila e implementa el contenedor de frontend

Una vez que el backend esté listo, captura su URL, escríbela directamente en .env.production e implementa el frontend.

cd ~/fe-genmedia-kiosk/frontend

# 1. Fetch Backend URL
export BACKEND_URL=$(gcloud run services describe fe-kiosk-backend \
  --region us-central1 \
  --format="value(status.url)")

echo "Configuring frontend with Backend URL: $BACKEND_URL"

# 2. Write it into the Vite production environment file
echo "VITE_API_BASE=${BACKEND_URL}" > .env.production

# 3. Deploy frontend (Vite will read .env.production during build)
gcloud run deploy fe-kiosk-frontend \
  --source . \
  --region us-central1 \
  --allow-unauthenticated \
  --port 8080

6. 🏁 Prueba y experimenta el kiosco de IA de la Fórmula E

  1. Abre $FRONTEND_URL en Google Chrome.
  2. Sube una selfie o una foto de retrato.
  3. Selecciona tu situación de carrera (Podium Winner Celebration o Pit Lane Gen4 Launch).
  4. Haz clic en START GENERATIVE PIPELINE.
  5. Observa el progreso en tiempo real en las tres etapas de la IA:
    • Gemini 3.8 Flash: Generación de instrucciones de controladores.
    • Nano Banana 2: Renderizado de avatares personalizados de autos y trajes de carreras de Gen4.
    • Gemini Omni Flash: Generación de videos en HD de 8 segundos a 60 fps.
  6. Reproduce tu video generativo final.

7. 🎉 Conclusión y limpieza

Qué aprendiste

✅ API de Gemini Platform & Interactions: Inicializa genai.Client(enterprise=True) y llama a client.interactions.create().

✅ Esquema de datos multimodales de la API de Interactions: Se usa la lista de diccionarios input y el parámetro de diccionario generation_config.

✅ Canalización de IA multimodal:

  • gemini-3.8-flash: Genera instrucciones personalizadas para los conductores.
  • gemini-3.1-flash-image (Nano Banana 2): Creación de avatares de pilotos y diseños de Gen4.
  • gemini-omni-1.1-flash-preview: Generación de video asíncrona con sondeo de tareas en segundo plano.

✅ Implementación sin servidores: Se alojaron en contenedores y se implementaron servicios en Google Cloud Run.

Limpieza de recursos

👉💻 Limpia tu entorno de Google Cloud:

gcloud run services delete fe-kiosk-backend --region us-central1 --quiet
gcloud run services delete fe-kiosk-frontend --region us-central1 --quiet
gcloud storage rm -r gs://${BUCKET_NAME}
gcloud iam service-accounts delete fe-kiosk-sa@${PROJECT_ID}.iam.gserviceaccount.com --quiet