1. Pengantar
Yang Akan Anda Bangun
Formula E AI Kiosk adalah pengalaman interaktif yang didukung oleh Gemini Platform dan Google GenAI Interactions API. Pengguna mengambil foto diri mereka, memilih skenario Formula E (seperti merayakan di podium atau meluncur dari pit lane), dan membiarkan Gemini mengubah mereka menjadi pembalap Formula E bersama mobil balap listrik Gen4.

Fokus Pembelajaran Interaktif
Dalam codelab ini, Anda akan menulis logika AI generatif inti menggunakan Interactions API di Gemini Platform (enterprise=True). Anda akan mengisi celah kode utama untuk:
- Inisialisasi Klien: Melakukan inisialisasi
genai.Client(enterprise=True)untuk Platform Gemini. - Gemini 3.8 Flash: Membuat perintah yang kaya dari foto pengguna menggunakan input multimodal berbasis dict (
[{"type": "text", ...}, {"type": "image", ...}]) dangeneration_config={"thinking_level": "high", "temperature": 0.2}. - Nano Banana 2/2.1 (
gemini-3.1-flash-image or gemini-nano-banana-2.1): Avatar pengemudi gambar ke gambar & pembuatan livery Gen4 menggunakan perintah yang diperluas. - Gemini Omni 1.1 Flash (
gemini-omni-1.1-flash-preview): Pembuatan video asinkron menggunakanbackground=Truedan pollingclient.interactions.get().
Jangan Khawatir Jika Mengalami Kendala Setiap latihan coding menyertakan blok 💡 Solusi & Penjelasan. Jika Anda mengalami masalah, scroll untuk melihat kode lengkap dan detail parameter.
Langkah-langkah penyiapan non-AI (scaffolding shell, konfigurasi Docker, dan deployment Cloud Run) tetap berupa perintah salin dan tempel yang disederhanakan, sehingga Anda dapat berfokus untuk menguasai Interactions API.
Branding & Aset Inti
Aset | Deskripsi | URL Sumber |
Mobil Formula E Gen4 | Referensi Mobil Balap Listrik Penuh Gen4 Resmi |
|
Logo Formula E x Google Cloud | Logo Transparan Kerja Sama Merek |
|
Font Google Sans Regular | Tipografi Merek Resmi |
|
Font Google Sans Medium | Tipografi Merek Resmi |
|
2. 🛠️ Penyiapan Lingkungan & Scaffolding Project
Anda akan membangun direktori ruang kerja dan file project langsung di Cloud Shell menggunakan perintah CLI modular.
1. Membuka Cloud Shell & Membuat Direktori Project
👉 Buka Cloud Shell Editor di browser Anda.
👉💻 Buat struktur ruang kerja:
mkdir -p ~/fe-genmedia-kiosk/{backend/app/{routers,services,prompts},frontend/{public/fonts,src},scripts}
cd ~/fe-genmedia-kiosk
2. Mengaktifkan Google Cloud API yang Diperlukan
👉💻 Tetapkan project ID Anda saat ini dan aktifkan layanan yang diperlukan:
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. Membuat Akun Layanan & Mengonfigurasi Izin IAM
Perhatikan bahwa jika Anda menggunakan akun GCP organisasi, dan tidak dapat membuat akun layanan, Anda akan memiliki Akun Layanan Compute Engine default dengan izin yang telah dikonfigurasi sebelumnya.
👉💻 Jalankan perintah konfigurasi untuk akun layanan runtime dan 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 Aset Merek Formula E
👉💻 Ambil aset kerja sama merek ke folder publik Anda:
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. Membuat Konfigurasi Lingkungan & Dependensi
👉💻 Buat .env dan 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. Layanan Gemini Seed Starter (backend/app/services/gemini_service.py)
👉💻 Buat file layanan awal yang berisi scaffolding dan TODO untuk tantangan interaktif, lalu salin dan tempel kode awal:
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. ⚡ Mengimplementasikan Interactions API & Model Generatif
Di bagian ini, Anda akan menulis logika AI generatif inti di backend/app/services/gemini_service.py menggunakan Interactions API dan GenerateContent API di Gemini Platform.
Buka backend/app/services/gemini_service.py di Cloud Shell Editor. Anda akan menemukan empat celah tantangan TODO yang harus diselesaikan.
Tantangan 1: Melakukan inisialisasi Gemini Enterprise Client
Buka backend/app/services/gemini_service.py. Selesaikan TODO 1 dengan menginisialisasi genai.Client yang dikonfigurasi untuk 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
💡 Butuh petunjuk atau mengalami kebuntuan? Scroll untuk melihat Solusi untuk TODO 1
client = genai.Client(
enterprise=True,
project=settings.project_id,
location=settings.location
)
Tantangan 2: Perluasan Perintah Driver dengan gemini-3.8-flash
Selesaikan TODO 2 di generate_driver_prompt(). Gunakan gemini-3.8-flash untuk menganalisis foto pengemudi dan menulis perintah mendetail untuk Nano Banana 2. Teruskan daftar dict ke input dan teruskan 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
💡 Butuh petunjuk atau mengalami kebuntuan? Klik untuk meluaskan Solusi untuk 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
}
)
Tantangan 3: Pembuatan Avatar dengan Nano Banana 2 (gemini-3.1-flash-image melalui GenerateContent API)
Selesaikan TODO 3 di generate_driver_avatar(). Panggil client.models.generate_content() dengan gemini-3.1-flash-image (Nano Banana 2) untuk merender avatar pengemudi Formula E dan mobil Gen4 yang bergaya.
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')
💡 Butuh petunjuk atau mengalami kebuntuan? Scroll untuk melihat Solusi untuk 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"),
)
)
Tantangan 4: Pembuatan Video dengan Gemini Omni 1.1 Flash
Selesaikan TODO 4 di generate_formula_e_video(). Mulai interaksi video asinkron dengan gemini-omni-1.1-flash-preview menggunakan background=True, lakukan polling client.interactions.get() hingga statusnya "completed", dan tampilkan byte video yang didekode 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)
💡 Butuh petunjuk atau mengalami kebuntuan? Klik untuk meluaskan Solusi untuk 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. 🏗️ Merakit Aplikasi Utama & Frontend
Sekarang, buat file wrapper aplikasi, endpoint API, dan antarmuka React yang tersisa.
[!IMPORTANT] Pastikan Anda menjalankan semua perintah pembuatan file di bagian ini dari direktori root ruang kerja (~/fe-genmedia-kiosk).
👉💻 Buka direktori root project:
cd ~/fe-genmedia-kiosk
1. Aplikasi FastAPI Utama (backend/app/main.py)
👉💻 Buat backend/app/main.py dan salin/tempel kode Python di bawah:
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. Dockerfile Backend (backend/Dockerfile)
👉💻 Buat konfigurasi container 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. Konfigurasi & Aplikasi React Frontend
Buat konfigurasi frontend dan file sumber langkah demi langkah untuk memastikan setiap file dibuat dengan baik di Cloud Shell tanpa masalah buffer tempel.
3.1 Manifes Paket (frontend/package.json)
👉💻 Buat definisi dependensi 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 Konfigurasi TypeScript (frontend/tsconfig.json)
👉💻 Buat frontend/tsconfig.json agar compiler TypeScript (tsc) dapat memvalidasi dan mem-build 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 Konfigurasi Build Vite (frontend/vite.config.ts)
👉💻 Buat konfigurasi 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 Titik Entri HTML (frontend/index.html)
👉💻 Buat titik entri HTML root:
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 Stylesheet Merek (frontend/src/index.css)
👉💻 Buat tema mode gelap kustom Formula 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 Komponen Root React (frontend/src/main.tsx)
👉💻 Buat file bootstrap aplikasi 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 UI Kios Interaktif (frontend/src/App.tsx)
👉💻 Buat antarmuka Kiosk interaktif utama dengan membuat App.tsx, lalu menyalin & menempelkan kode 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 Konfigurasi Server Nginx (frontend/nginx.conf)
👉💻 Buat konfigurasi server web Nginx untuk pemilihan rute aplikasi web satu halaman:
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 Penyiapan Build Container Frontend (frontend/Dockerfile & frontend/.dockerignore)
👉💻 Buat .dockerignore dan Dockerfile produksi multi-level:
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. 🚀 Men-deploy ke Cloud Run
1. Membangun & Men-deploy Container Backend
👉💻 Deploy server backend ke Cloud Run:
cd ~/fe-genmedia-kiosk/backend
gcloud run deploy fe-kiosk-backend \
--source . \
--region us-central1 \
--allow-unauthenticated \
--port 8080
2. Membangun & Men-deploy Container Frontend
Setelah backend selesai, ambil URL-nya, tulis langsung ke .env.production, dan deploy 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. 🏁 Menguji & Mencoba Kios AI Formula E
- Buka
$FRONTEND_URLdi Google Chrome. - Upload foto selfie atau potret.
- Pilih skenario balapan Anda (Podium Winner Celebration atau Pit Lane Gen4 Launch).
- Klik START GENERATIVE PIPELINE.
- Amati progres real-time di ketiga tahap AI:
- Gemini 3.8 Flash: Pembuatan perintah driver.
- Nano Banana 2: Rendering avatar kustom mobil & pakaian balap Gen4.
- Gemini Omni Flash: Pembuatan video HD 60 fps berdurasi 8 detik.
- Putar video generatif akhir Anda.
7. 🎉 Kesimpulan & Pembersihan
Yang Telah Anda Pelajari
✅ Gemini Platform & Interactions API: Melakukan inisialisasi genai.Client(enterprise=True) dan memanggil client.interactions.create().
✅ Skema Data Multimodal Interactions API: Menggunakan parameter dict input dan generation_config list-of-dicts.
Pipeline AI Multimodal: ✅
gemini-3.8-flash: Membuat perintah pengemudi yang disesuaikan.gemini-3.1-flash-image(Nano Banana 2): Pembuatan avatar pengemudi & livery Gen4.gemini-omni-1.1-flash-preview: Pembuatan video asinkron dengan polling tugas latar belakang.
✅ Deployment Serverless: Layanan dalam container dan di-deploy ke Google Cloud Run.
Pembersihan Resource
👉💻 Bersihkan lingkungan Google Cloud Anda:
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