Build & Deploy a Formula E AI Kiosk with Gemini Omni & Cloud Run

1. Introduction

What You'll Build

The Formula E AI Kiosk is an interactive experience powered by Gemini Platform and the Google GenAI Interactions API. Users take a photo of themselves, choose a Formula E scenario (such as celebrating on the podium or launching from the pit lane), and let Gemini transform them into a Formula E race driver alongside a Gen4 electric race car.

solution diagram for Kiosk Lab

Interactive Learning Focus

In this codelab, you will write the core generative AI logic using the Interactions API on the Gemini Platform (enterprise=True). You will fill in key code gaps for:

  1. Client Initialization: Initializing genai.Client(enterprise=True) for Gemini Platform.
  2. Gemini 3.8 Flash: Generating a rich prompt from the user photo using dict-based multimodal input ([{"type": "text", ...}, {"type": "image", ...}]) and 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): Image-to-image driver avatar & Gen4 livery creation using the expanded prompt.
  4. Gemini Omni 1.1 Flash (gemini-omni-1.1-flash-preview): Asynchronous video generation using background=True and polling client.interactions.get().

Don't Worry About Getting Stuck! Each coding exercise includes a 💡 Solution & Explanation block. If you run into issues, scroll to see the full code and parameter details.

Non-AI setup steps (shell scaffolding, Docker configs, and Cloud Run deployment) remain streamlined copy-and-paste commands so you can focus on mastering the Interactions API.

Core Branding & Assets

Asset

Description

Source URL

Gen4 Formula E Car

Official Gen4 All-Electric Race Car Reference

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

Formula E x Google Cloud Logo

Co-branded Transparent Logo

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

Google Sans Regular Font

Official Brand Typography

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

Google Sans Medium Font

Official Brand Typography

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

2. 🛠️ Environment Setup & Project Scaffolding

You will build the workspace directory and project files directly in Cloud Shell using modular CLI commands.

1. Open Cloud Shell & Create Project Directory

👉 Open Cloud Shell Editor in your browser.

👉💻 Create the workspace structure:

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

2. Enable Required Google Cloud APIs

👉💻 Set your current project ID and enable the required services:

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. Create Service Account & Configure IAM Permissions

Note if you are using an org GCP account, and cannot generate a service account, you will instead have a default Compute Engine Service Account with preconfigured permissions.

👉💻 Run the configuration commands for the runtime service account and 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. Download Formula E Brand Assets

👉💻 Fetch the co-branded assets into your public folder:

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. Create Environment & Dependencies Config

👉💻 Create .env and 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. Seed Starter Gemini Service (backend/app/services/gemini_service.py)

👉💻 Create the starter service file containing the scaffolding and TODO gaps for the hands-on challenges and copy and paste the starter code:

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. ⚡ Implementing the Interactions API & Generative Models

In this section, you will write the core generative AI logic in backend/app/services/gemini_service.py using the Interactions API and GenerateContent API on Gemini Platform.

Open backend/app/services/gemini_service.py in Cloud Shell Editor. You will find four TODO challenge gaps to complete.

Challenge 1: Initialize the Gemini Enterprise Client

Open backend/app/services/gemini_service.py. Complete TODO 1 by initializing the genai.Client configured for 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

💡 Need a hint or stuck? Scroll to see the Solution for TODO 1

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

Challenge 2: Driver Prompt Expansion with gemini-3.8-flash

Complete TODO 2 in generate_driver_prompt(). Use gemini-3.8-flash to analyze the driver photo and write a detailed prompt for Nano Banana 2. Pass a list of dicts to input and pass 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

💡 Need a hint or stuck? Click to expand Solution for 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
        }
    )

Challenge 3: Avatar Generation with Nano Banana 2 (gemini-3.1-flash-image via GenerateContent API)

Complete TODO 3 in generate_driver_avatar(). Call client.models.generate_content() with gemini-3.1-flash-image (Nano Banana 2) to render the stylized Formula E driver avatar and Gen4 car.

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

💡 Need a hint or stuck? Scroll to see Solution for 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"),
        )
      )

Challenge 4: Video Generation with Gemini Omni 1.1 Flash

Complete TODO 4 in generate_formula_e_video(). Start an asynchronous video interaction with gemini-omni-1.1-flash-preview using background=True, poll client.interactions.get() until status is "completed", and return the base64-decoded video bytes (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)

💡 Need a hint or stuck? Click to expand Solution for 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. 🏗️ Assembling Main Application & Frontend

Now create the remaining application wrapper files, API endpoints, and React interface.

[!IMPORTANT] Ensure you run all file creation commands in this section from the root workspace directory (~/fe-genmedia-kiosk).

👉💻 Navigate to the project root directory:

cd ~/fe-genmedia-kiosk

1. Main FastAPI Application (backend/app/main.py)

👉💻 Create backend/app/main.py and copy/paste the Python code below:

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)

👉💻 Create the backend container configuration:

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. Frontend React Application & Configuration

Create the frontend configuration and source files step-by-step to ensure each file is generated cleanly in Cloud Shell without paste buffer issues.

3.1 Package Manifest (frontend/package.json)

👉💻 Create the frontend dependencies definition:

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 TypeScript Configuration (frontend/tsconfig.json)

👉💻 Create frontend/tsconfig.json so the TypeScript compiler (tsc) can validate and build the project:

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 Vite Build Configuration (frontend/vite.config.ts)

👉💻 Create Vite configuration:

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 HTML Entry Point (frontend/index.html)

👉💻 Create the root HTML entry point:

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 Brand Stylesheet (frontend/src/index.css)

👉💻 Create the Formula E custom dark-mode theme:

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 React Root Component (frontend/src/main.tsx)

👉💻 Create the React application bootstrap file:

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 Interactive Kiosk UI (frontend/src/App.tsx)

👉💻 Create the main interactive Kiosk interface by creating App.tsx and copying & pasting the React code:

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 Nginx Server Configuration (frontend/nginx.conf)

👉💻 Create the Nginx web server configuration for single-page application routing:

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 Frontend Container Build Setup (frontend/Dockerfile & frontend/.dockerignore)

👉💻 Create .dockerignore and the multi-stage production Dockerfile:

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. 🚀 Deploying to Cloud Run

1. Build & Deploy Backend Container

👉💻 Deploy the backend server to Cloud Run:

cd ~/fe-genmedia-kiosk/backend

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

2. Build & Deploy Frontend Container

Once the backend is finished, capture its URL, write it directly to .env.production and deploy the 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. 🏁 Testing & Experiencing the Formula E AI Kiosk

  1. Open $FRONTEND_URL in Google Chrome.
  2. Upload a selfie or portrait photo.
  3. Select your racing scenario (Podium Winner Celebration or Pit Lane Gen4 Launch).
  4. Click START GENERATIVE PIPELINE.
  5. Watch the real-time progress across all three AI stages:
    • Gemini 3.8 Flash: Driver prompt generation.
    • Nano Banana 2: Custom Gen4 car & race suit avatar rendering.
    • Gemini Omni Flash: 8-second 60fps HD video generation.
  6. Play back your final generative video!

7. 🎉 Conclusion & Clean-up

What You've Learned

✅ Gemini Platform & Interactions API: Initializing genai.Client(enterprise=True) and calling client.interactions.create().

✅ Interactions API Multimodal Data Schema: Using list-of-dicts input and generation_config dict parameter.

✅ Multimodal AI Pipeline:

  • gemini-3.8-flash: Generating customized driver prompts.
  • gemini-3.1-flash-image (Nano Banana 2): Driver avatar & Gen4 livery creation.
  • gemini-omni-1.1-flash-preview: Asynchronous video generation with background task polling.

✅ Serverless Deployment: Containerized and deployed services to Google Cloud Run.

Resource Clean-up

👉💻 Clean up your Google Cloud environment:

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