1. Giới thiệu
Sản phẩm bạn sẽ tạo ra
Formula E AI Kiosk là một trải nghiệm tương tác dựa trên Nền tảng Gemini và Google GenAI Interactions API. Người dùng chụp ảnh bản thân, chọn một bối cảnh trong Formula E (chẳng hạn như ăn mừng trên bục vinh quang hoặc xuất phát từ làn đường pit) và để Gemini biến họ thành một tay đua Formula E bên cạnh một chiếc xe đua điện Gen4.

Tập trung vào hoạt động học tập tương tác
Trong lớp học lập trình này, bạn sẽ viết logic AI tạo sinh cốt lõi bằng Interactions API trên Nền tảng Gemini (enterprise=True). Bạn sẽ điền vào các khoảng trống mã chính cho:
- Khởi chạy ứng dụng: Khởi chạy
genai.Client(enterprise=True)cho Nền tảng Gemini. - Gemini 3.8 Flash: Tạo câu lệnh phong phú từ ảnh của người dùng bằng cách sử dụng dữ liệu đầu vào đa phương thức dựa trên từ điển (
[{"type": "text", ...}, {"type": "image", ...}]) vàgeneration_config={"thinking_level": "high", "temperature": 0.2}. - Nano Banana 2/2.1 (
gemini-3.1-flash-image or gemini-nano-banana-2.1): Tạo hình đại diện xe ô tô từ hình ảnh và tạo màu sơn Gen4 bằng câu lệnh mở rộng. - Gemini Omni 1.1 Flash (
gemini-omni-1.1-flash-preview): Tạo video không đồng bộ bằngbackground=Truevà thăm dò ý kiếnclient.interactions.get().
Đừng lo nếu bạn gặp khó khăn! Mỗi bài tập lập trình đều có một khối 💡 Giải pháp và giải thích. Nếu bạn gặp vấn đề, hãy di chuyển xuống để xem toàn bộ mã và thông tin chi tiết về tham số.
Các bước thiết lập không liên quan đến AI (giàn giáo shell, cấu hình Docker và triển khai Cloud Run) vẫn là các lệnh sao chép và dán đơn giản để bạn có thể tập trung vào việc nắm vững Interactions API.
Thương hiệu và thành phần cốt lõi
Thành phần | Mô tả | URL Nguồn |
Xe đua Formula E thế hệ thứ 4 | Tài liệu tham khảo chính thức về xe đua thuần điện thế hệ thứ 4 |
|
Biểu trưng Formula E x Google Cloud | Biểu trưng trong suốt thể hiện sự hợp tác thương hiệu |
|
Phông chữ Google Sans Regular | Kiểu chữ chính thức của thương hiệu |
|
Phông chữ Google Sans Medium | Kiểu chữ chính thức của thương hiệu |
|
2. 🛠️ Thiết lập môi trường và tạo khung dự án
Bạn sẽ tạo thư mục không gian làm việc và các tệp dự án ngay trong Cloud Shell bằng cách sử dụng các lệnh CLI mô-đun.
1. Mở Cloud Shell và tạo thư mục dự án
👉 Mở Trình chỉnh sửa Cloud Shell trong trình duyệt.
👉💻 Tạo cấu trúc không gian làm việc:
mkdir -p ~/fe-genmedia-kiosk/{backend/app/{routers,services,prompts},frontend/{public/fonts,src},scripts}
cd ~/fe-genmedia-kiosk
2. Bật các API Google Cloud bắt buộc
👉💻 Đặt mã dự án hiện tại và bật các dịch vụ cần thiết:
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. Tạo tài khoản dịch vụ và định cấu hình quyền IAM
Lưu ý: Nếu đang sử dụng tài khoản GCP của tổ chức và không thể tạo tài khoản dịch vụ, bạn sẽ có một Tài khoản dịch vụ Compute Engine mặc định với các quyền được định cấu hình sẵn.
👉💻 Chạy các lệnh cấu hình cho tài khoản dịch vụ thời gian chạy và 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. Tải tài sản thương hiệu Formula E xuống
👉💻 Tìm nạp các thành phần có thương hiệu chung vào thư mục công khai:
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. Tạo cấu hình môi trường và phần phụ thuộc
👉💻 Tạo .env và 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. Dịch vụ Gemini Seed Starter (backend/app/services/gemini_service.py)
👉💻 Tạo tệp dịch vụ khởi đầu chứa mã mẫu và các khoảng trống TODO cho các thử thách thực hành, sau đó sao chép và dán mã khởi đầu:
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. ⚡ Triển khai Interactions API và Mô hình tạo sinh
Trong phần này, bạn sẽ viết logic AI tạo sinh cốt lõi trong backend/app/services/gemini_service.py bằng Interactions API và GenerateContent API trên Nền tảng Gemini.
Mở backend/app/services/gemini_service.py trong Trình chỉnh sửa Cloud Shell. Bạn sẽ thấy 4 khoảng trống thử thách TODO cần hoàn thành.
Thử thách 1: Khởi chạy ứng dụng Gemini Enterprise
Mở backend/app/services/gemini_service.py. Hoàn tất TODO 1 bằng cách khởi chạy genai.Client được định cấu hình cho 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
💡 Bạn cần gợi ý hoặc gặp khó khăn? Di chuyển xuống để xem Giải pháp cho TODO 1
client = genai.Client(
enterprise=True,
project=settings.project_id,
location=settings.location
)
Thử thách 2: Mở rộng câu lệnh của tài xế bằng gemini-3.8-flash
Hoàn thành TODO 2 trong generate_driver_prompt(). Dùng gemini-3.8-flash để phân tích ảnh của tài xế và viết câu lệnh chi tiết cho Nano Banana 2. Truyền danh sách từ điển đến input và truyền 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
💡 Bạn cần gợi ý hoặc gặp khó khăn? Nhấp để mở rộng Giải pháp cho 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
}
)
Thử thách 3: Tạo hình đại diện bằng Nano Banana 2 (gemini-3.1-flash-image thông qua API GenerateContent)
Hoàn tất TODO 3 trong generate_driver_avatar(). Gọi client.models.generate_content() bằng gemini-3.1-flash-image (Nano Banana 2) để hiển thị hình đại diện xe ô tô tay đua Formula E và xe Gen4 được cách điệu.
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')
💡 Bạn cần gợi ý hoặc gặp khó khăn? Cuộn để xem Giải pháp cho 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"),
)
)
Thử thách 4: Tạo video bằng Gemini Omni 1.1 Flash
Hoàn tất TODO 4 trong generate_formula_e_video(). Bắt đầu tương tác video không đồng bộ với gemini-omni-1.1-flash-preview bằng background=True, thăm dò ý kiến client.interactions.get() cho đến khi trạng thái là "đã hoàn tất" và trả về các byte video được giải mã 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)
💡 Bạn cần gợi ý hoặc gặp khó khăn? Nhấp để mở rộng Giải pháp cho 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. 🏗️ Lắp ráp ứng dụng chính và giao diện người dùng
Bây giờ, hãy tạo các tệp trình bao bọc ứng dụng, điểm cuối API và giao diện React còn lại.
[!IMPORTANT] Đảm bảo bạn chạy tất cả lệnh tạo tệp trong phần này từ thư mục không gian làm việc gốc (~/fe-genmedia-kiosk).
👉💻 Chuyển đến thư mục gốc của dự án:
cd ~/fe-genmedia-kiosk
1. Ứng dụng FastAPI chính (backend/app/main.py)
👉💻 Tạo backend/app/main.py rồi sao chép/dán mã Python bên dưới:
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 của phần phụ trợ (backend/Dockerfile)
👉💻 Tạo cấu hình vùng chứa phụ trợ:
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. Cấu hình và ứng dụng React giao diện người dùng
Tạo tệp cấu hình và tệp nguồn giao diện người dùng theo từng bước để đảm bảo mỗi tệp được tạo một cách rõ ràng trong Cloud Shell mà không gặp vấn đề về vùng đệm dán.
3.1 Tệp kê khai gói (frontend/package.json)
👉💻 Tạo định nghĩa phần phụ thuộc giao diện người dùng:
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 Cấu hình TypeScript (frontend/tsconfig.json)
👉💻 Tạo frontend/tsconfig.json để trình biên dịch TypeScript (tsc) có thể xác thực và tạo dự án:
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 Cấu hình bản dựng Vite (frontend/vite.config.ts)
👉💻 Tạo cấu hình 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 Điểm truy cập HTML (frontend/index.html)
👉💻 Tạo điểm truy cập HTML gốc:
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 Biểu định kiểu thương hiệu (frontend/src/index.css)
👉💻 Tạo giao diện chế độ tối tuỳ chỉnh cho 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 Thành phần gốc React (frontend/src/main.tsx)
👉💻 Tạo tệp khởi động ứng dụng 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 Giao diện người dùng của ki-ốt tương tác (frontend/src/App.tsx)
👉💻 Tạo giao diện Kiosk tương tác chính bằng cách tạo App.tsx rồi sao chép và dán mã 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 Cấu hình máy chủ Nginx (frontend/nginx.conf)
👉💻 Tạo cấu hình máy chủ web Nginx để định tuyến ứng dụng trang đơn:
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 Thiết lập bản dựng vùng chứa giao diện người dùng (frontend/Dockerfile và frontend/.dockerignore)
👉💻 Tạo .dockerignore và quy trình sản xuất nhiều giai đoạn 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. 🚀 Đang triển khai lên Cloud Run
1. Xây dựng và triển khai vùng chứa phần phụ trợ
👉💻 Triển khai máy chủ phụ trợ lên Cloud Run:
cd ~/fe-genmedia-kiosk/backend
gcloud run deploy fe-kiosk-backend \
--source . \
--region us-central1 \
--allow-unauthenticated \
--port 8080
2. Xây dựng và triển khai vùng chứa giao diện người dùng
Sau khi hoàn tất phần phụ trợ, hãy lấy URL của phần phụ trợ, ghi trực tiếp vào .env.production và triển khai Giao diện người dùng
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. 🏁 Thử nghiệm và trải nghiệm Kiosk AI của Formula E
- Mở
$FRONTEND_URLtrong Google Chrome. - Tải ảnh chân dung tự chụp hoặc ảnh chân dung lên.
- Chọn kịch bản đua xe (Podium Winner Celebration hoặc Pit Lane Gen4 Launch).
- Nhấp vào BẮT ĐẦU QUY TRÌNH SÁNG TẠO.
- Theo dõi tiến độ theo thời gian thực trong cả ba giai đoạn AI:
- Gemini 3.8 Flash: Tạo câu lệnh cho tài xế.
- Nano Banana 2: Kết xuất hình đại diện xe đua và bộ đồ đua Gen4 tuỳ chỉnh.
- Gemini Omni Flash: Tạo video HD dài 8 giây ở tốc độ 60 khung hình/giây.
- Phát video cuối cùng do AI tạo!
7. 🎉 Kết luận và dọn dẹp
Kiến thức bạn học được
✅ Nền tảng Gemini và Interactions API: Khởi chạy genai.Client(enterprise=True) và gọi client.interactions.create().
✅ Lược đồ dữ liệu đa phương thức của Interactions API: Sử dụng tham số dict input và generation_config list-of-dicts.
✅ Quy trình AI đa phương thức:
gemini-3.8-flash: Đang tạo câu lệnh tuỳ chỉnh cho tài xế.gemini-3.1-flash-image(Nano Banana 2): Tạo hình đại diện cho tay đua và thiết kế xe Gen4.gemini-omni-1.1-flash-preview: Tạo video không đồng bộ bằng tính năng thăm dò tác vụ trong nền.
✅ Triển khai không máy chủ: Đưa vào vùng chứa và triển khai các dịch vụ lên Google Cloud Run.
Dọn dẹp tài nguyên
👉💻 Dọn dẹp môi trường 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