1. บทนำ
สิ่งที่คุณจะสร้าง
คีออสก์ AI ของ Formula E เป็นประสบการณ์แบบอินเทอร์แอกทีฟที่ขับเคลื่อนโดยแพลตฟอร์ม Gemini และ Google GenAI Interactions API ผู้ใช้ถ่ายรูปตัวเอง เลือกสถานการณ์ Formula E (เช่น ฉลองบนโพเดียมหรือออกตัวจากพิตเลน) แล้วให้ Gemini เปลี่ยนตัวเองเป็นนักแข่งรถ Formula E พร้อมกับรถแข่งไฟฟ้า Gen4

เน้นการเรียนรู้แบบอินเทอร์แอกทีฟ
ใน Codelab นี้ คุณจะเขียนตรรกะ Generative AI หลักโดยใช้ Interactions API ในแพลตฟอร์ม Gemini (enterprise=True) โดยจะเติมช่องว่างของโค้ดที่สำคัญสำหรับสิ่งต่อไปนี้
- การเริ่มต้นไคลเอ็นต์: กำลังเริ่มต้น
genai.Client(enterprise=True)สำหรับแพลตฟอร์ม Gemini - Gemini 3.8 Flash: สร้างพรอมต์ที่สมบูรณ์จากรูปภาพของผู้ใช้โดยใช้อินพุตหลายรูปแบบที่อิงตามพจนานุกรม (
[{"type": "text", ...}, {"type": "image", ...}]) และgeneration_config={"thinking_level": "high", "temperature": 0.2} - Nano Banana 2/2.1 (
gemini-3.1-flash-image or gemini-nano-banana-2.1): อวาตาร์การขับขี่แบบรูปภาพต่อรูปภาพและการสร้างลายรถ Gen4 โดยใช้พรอมต์ที่ขยาย - Gemini Omni 1.1 Flash (
gemini-omni-1.1-flash-preview): การสร้างวิดีโอแบบไม่พร้อมกันโดยใช้background=Trueและการโพลclient.interactions.get()
ไม่ต้องกังวลว่าจะติดขัด แบบฝึกหัดการเขียนโค้ดแต่ละรายการจะมีบล็อก💡 โซลูชันและคำอธิบาย หากพบปัญหา ให้เลื่อนเพื่อดูรายละเอียดโค้ดและพารามิเตอร์ทั้งหมด
ขั้นตอนการตั้งค่าที่ไม่ใช่ AI (โครงสร้าง Shell, การกำหนดค่า Docker และการติดตั้งใช้งาน Cloud Run) ยังคงเป็นคำสั่งคัดลอกและวางที่ปรับปรุงให้มีประสิทธิภาพมากขึ้น เพื่อให้คุณมุ่งเน้นที่การทำความเข้าใจ Interactions API ได้อย่างเต็มที่
การสร้างแบรนด์และชิ้นงานหลัก
ชิ้นงาน | คำอธิบาย | URL ต้นทาง |
รถ Formula E รุ่นที่ 4 | ข้อมูลอ้างอิงเกี่ยวกับรถแข่งไฟฟ้าทั้งหมด Gen4 อย่างเป็นทางการ |
|
โลโก้ Formula E x Google Cloud | โลโก้โปร่งใสที่มีการสร้างแบรนด์ร่วม |
|
แบบอักษร Google Sans Regular | ตัวพิมพ์อย่างเป็นทางการของแบรนด์ |
|
แบบอักษร Google Sans Medium | ตัวพิมพ์อย่างเป็นทางการของแบรนด์ |
|
2. 🛠️ การตั้งค่าสภาพแวดล้อมและโครงสร้างโปรเจ็กต์
คุณจะสร้างไดเรกทอรีพื้นที่ทำงานและไฟล์โปรเจ็กต์ใน Cloud Shell โดยตรงโดยใช้คำสั่ง CLI แบบโมดูล
1. เปิด Cloud Shell และสร้างไดเรกทอรีโปรเจ็กต์
👉 เปิด Cloud Shell Editor ในเบราว์เซอร์
👉💻 สร้างโครงสร้างพื้นที่ทำงาน
mkdir -p ~/fe-genmedia-kiosk/{backend/app/{routers,services,prompts},frontend/{public/fonts,src},scripts}
cd ~/fe-genmedia-kiosk
2. เปิดใช้ Google Cloud API ที่จำเป็น
👉💻 ตั้งค่ารหัสโปรเจ็กต์ปัจจุบันและเปิดใช้บริการที่จำเป็น
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. สร้างบัญชีบริการและกำหนดค่าสิทธิ์ IAM
โปรดทราบว่าหากคุณใช้บัญชี GCP ขององค์กรและสร้างบัญชีบริการไม่ได้ คุณจะมีบัญชีบริการ Compute Engine เริ่มต้นที่มีสิทธิ์ที่กำหนดค่าไว้ล่วงหน้าแทน
👉💻 เรียกใช้คำสั่งการกำหนดค่าสำหรับบัญชีบริการรันไทม์และ 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. ดาวน์โหลดชิ้นงานของแบรนด์ Formula E
👉💻 ดึงชิ้นงานของแบรนด์ที่มีการสร้างแบรนด์ร่วมไปยังโฟลเดอร์สาธารณะของคุณโดยทำดังนี้
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. สร้างการกำหนดค่าสภาพแวดล้อมและการขึ้นต่อกัน
👉💻 สร้าง .env และ 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 (backend/app/services/gemini_service.py)
👉💻 สร้างไฟล์บริการเริ่มต้นที่มีโครงสร้างและช่องว่าง TODO สำหรับภารกิจที่ได้ลงมือทำจริง แล้วคัดลอกและวางโค้ดเริ่มต้นต่อไปนี้
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. ⚡ การติดตั้งใช้งาน Interactions API และโมเดล Generative
ในส่วนนี้ คุณจะเขียนตรรกะ Generative AI หลักใน backend/app/services/gemini_service.py โดยใช้ Interactions API และ GenerateContent API บนแพลตฟอร์ม Gemini
เปิด backend/app/services/gemini_service.py ใน Cloud Shell Editor คุณจะเห็นช่องว่างของชาเลนจ์ TODO 4 รายการที่ต้องทำให้เสร็จ
ภารกิจที่ 1: เริ่มต้นไคลเอ็นต์ Gemini Enterprise
เปิด backend/app/services/gemini_service.py ทำ TODO 1 ให้เสร็จโดยการเริ่มต้น genai.Client ที่กำหนดค่าไว้สำหรับ 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
💡 หากต้องการคำแนะนำหรือพบปัญหา เลื่อนเพื่อดูวิธีแก้ปัญหาสำหรับสิ่งที่ต้องทำ 1
client = genai.Client(
enterprise=True,
project=settings.project_id,
location=settings.location
)
โจทย์ที่ 2: การขยายพรอมต์ของไดรเวอร์ด้วย gemini-3.8-flash
ทำ TODO 2 ใน generate_driver_prompt() ให้เสร็จสมบูรณ์ ใช้ gemini-3.8-flash เพื่อวิเคราะห์รูปภาพคนขับและเขียนพรอมต์แบบละเอียดสำหรับ Nano Banana 2 ส่งรายการ Dict ไปยัง input และส่ง 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
💡 หากต้องการคำแนะนำหรือพบปัญหา คลิกเพื่อขยายวิธีแก้ปัญหาสำหรับสิ่งที่ต้องทำ 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
}
)
ภารกิจที่ 3: การสร้างรูปโปรไฟล์ด้วย Nano Banana 2 (gemini-3.1-flash-image ผ่าน GenerateContent API)
ทำ TODO 3 ใน generate_driver_avatar() ให้เสร็จสมบูรณ์ เรียกใช้ client.models.generate_content() ด้วย gemini-3.1-flash-image (Nano Banana 2) เพื่อแสดงผลอวาตาร์นักแข่ง Formula E และรถ Gen4 ที่มีสไตล์
def generate_driver_avatar(driver_prompt: str, image_bytes: bytes) -> bytes:
"""Uses Nano Banana 2 (gemini-3.1-flash-image) to generate stylized driver avatar."""
text_parts = types.Part.from_text(text=f"Generate a stylized Formula E driver portrait: {driver_prompt}")
image_parts = types.Part.from_bytes(data=image_bytes,
mime_type='image/jpeg',)
gen4_image_parts = types.Part.from_uri(file_uri="https://coffeecornermotorsport.com/wp-content/uploads/2026/04/m37258_GEN4-HERO3.webp", mime_type="image/webp")
# =====================================================================
# TODO 3: Call client.models.generate_content() for image generation
# Model: "gemini-3.1-flash-image" or "gemini-nano-banana-2.1"
# Content: A list with the parts
# Generation Config: Image Response Modality and 16:9 Aspect ratio
# =====================================================================
response = None # REPLACE THIS WITH YOUR CODE
generated_bytes = None
if response.candidates and response.candidates[0].content.parts:
for part in response.candidates[0].content.parts:
if part.inline_data and part.inline_data.data:
generated_bytes = part.inline_data.data
break
return base64.b64encode(generated_bytes).decode('utf-8')
💡 หากต้องการคำแนะนำหรือพบปัญหา เลื่อนเพื่อดูวิธีแก้ปัญหาสำหรับสิ่งที่ต้องทำ 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"),
)
)
โจทย์ที่ 4: การสร้างวิดีโอด้วย Gemini Omni 1.1 Flash
ทำสิ่งที่ต้องทำ 4 ใน generate_formula_e_video() ให้เสร็จสมบูรณ์ เริ่มการโต้ตอบวิดีโอแบบอะซิงโครนัสกับ gemini-omni-1.1-flash-preview โดยใช้ background=True, โพล client.interactions.get() จนกว่าสถานะจะเป็น "เสร็จสมบูรณ์" และแสดงผลไบต์วิดีโอที่ถอดรหัส 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)
💡 หากต้องการคำแนะนำหรือพบปัญหา คลิกเพื่อขยายวิธีแก้ปัญหาสำหรับสิ่งที่ต้องทำ 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. 🏗️ การประกอบแอปพลิเคชันหลักและฟรอนท์เอนด์
ตอนนี้ให้สร้างไฟล์ Wrapper ของแอปพลิเคชัน, ปลายทาง API และอินเทอร์เฟซ React ที่เหลือ
[!สำคัญ] ตรวจสอบว่าคุณเรียกใช้คำสั่งการสร้างไฟล์ทั้งหมดในส่วนนี้จากไดเรกทอรี Workspace รูท (~/fe-genmedia-kiosk)
👉💻 ไปที่ไดเรกทอรีรากของโปรเจ็กต์
cd ~/fe-genmedia-kiosk
1. แอปพลิเคชัน FastAPI หลัก (backend/app/main.py)
👉💻 สร้าง backend/app/main.py แล้วคัดลอก/วางโค้ด Python ด้านล่าง
touch backend/app/main.py
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import asyncio
import uuid
from app.services.gemini_service import (
generate_driver_prompt,
generate_driver_avatar,
generate_formula_e_video,
upload_to_gcs
)
app = FastAPI(title="Formula E AI Kiosk API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
jobs_db = {}
class JobStatus(BaseModel):
job_id: str
status: str
stage: str
result_url: str | None = None
@app.get("/api/health")
def health_check():
return {"status": "ok", "experience": "Formula E Gen4 AI Kiosk"}
@app.post("/api/process-driver")
async def process_driver(
file: UploadFile = File(...),
option: str = Form("podium_celebration")
):
job_id = str(uuid.uuid4())
image_bytes = await file.read()
jobs_db[job_id] = {
"status": "processing",
"stage": "generating_prompt",
"result_url": None
}
asyncio.create_task(run_kiosk_pipeline(job_id, image_bytes, option))
return {"job_id": job_id, "status": "queued"}
async def run_kiosk_pipeline(job_id: str, image_bytes: bytes, option: str):
try:
jobs_db[job_id]["stage"] = "Writing driver prompt with Gemini 3.8 Flash..."
driver_prompt = generate_driver_prompt(image_bytes, option)
jobs_db[job_id]["stage"] = "Rendering Nano Banana 2 avatar..."
avatar_bytes = generate_driver_avatar(driver_prompt, image_bytes)
jobs_db[job_id]["stage"] = "Generating Gemini Omni Flash video..."
video_bytes = generate_formula_e_video(driver_prompt, avatar_bytes)
jobs_db[job_id]["stage"] = "Publishing generated video to Cloud Storage..."
result_url = upload_to_gcs(video_bytes, f"videos/{job_id}.mp4", "video/mp4")
jobs_db[job_id]["status"] = "completed"
jobs_db[job_id]["stage"] = "Done"
jobs_db[job_id]["result_url"] = result_url
except Exception as e:
jobs_db[job_id]["status"] = "failed"
jobs_db[job_id]["stage"] = str(e)
@app.get("/api/job/{job_id}", response_model=JobStatus)
def get_job_status(job_id: str):
if job_id not in jobs_db:
raise HTTPException(status_code=404, detail="Job not found")
data = jobs_db[job_id]
return JobStatus(
job_id=job_id,
status=data["status"],
stage=data["stage"],
result_url=data.get("result_url")
)
2. Dockerfile แบ็กเอนด์ (backend/Dockerfile)
👉💻 สร้างการกำหนดค่าคอนเทนเนอร์ฝั่งเซิร์ฟเวอร์
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. แอปพลิเคชันและการกำหนดค่า React ของฟรอนท์เอนด์
สร้างการกำหนดค่าฟรอนท์เอนด์และไฟล์ต้นฉบับทีละขั้นตอนเพื่อให้แน่ใจว่าแต่ละไฟล์ได้รับการสร้างอย่างสมบูรณ์ใน Cloud Shell โดยไม่มีปัญหาเกี่ยวกับบัฟเฟอร์การวาง
3.1 ไฟล์ Manifest ของแพ็กเกจ (frontend/package.json)
👉💻 สร้างคำจำกัดความของทรัพยากร Dependency ของฟรอนท์เอนด์
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 (frontend/tsconfig.json)
👉💻 สร้าง frontend/tsconfig.json เพื่อให้คอมไพเลอร์ TypeScript (tsc) ตรวจสอบและสร้างโปรเจ็กต์ได้
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 (frontend/vite.config.ts)
👉💻 สร้างการกำหนดค่า 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 จุดแรกเข้า HTML (frontend/index.html)
👉💻 สร้างจุดแรกเข้า HTML ระดับรูท
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 สไตล์ชีตของแบรนด์ (frontend/src/index.css)
👉💻 สร้างธีมโหมดมืดที่กำหนดเองของ 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 คอมโพเนนต์ React Root (frontend/src/main.tsx)
👉💻 สร้างไฟล์ Bootstrap ของแอปพลิเคชัน 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 ของคีออสก์แบบอินเทอร์แอกทีฟ (frontend/src/App.tsx)
👉💻 สร้างอินเทอร์เฟซคีออสก์แบบอินเทอร์แอกทีฟหลักโดยสร้าง App.tsx แล้วคัดลอกและวางโค้ด 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 การกำหนดค่าเซิร์ฟเวอร์ Nginx (frontend/nginx.conf)
👉💻 สร้างการกำหนดค่าเว็บเซิร์ฟเวอร์ Nginx สำหรับการกำหนดเส้นทางแอปพลิเคชันหน้าเว็บเดียว
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/Dockerfile และ frontend/.dockerignore)
👉💻 สร้าง .dockerignore และการผลิตแบบหลายระดับ 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. 🚀 กำลังทำให้ใช้งานได้กับ Cloud Run
1. สร้างและติดตั้งใช้งานคอนเทนเนอร์แบ็กเอนด์
👉💻 ติดตั้งใช้งานเซิร์ฟเวอร์แบ็กเอนด์ใน Cloud Run โดยทำดังนี้
cd ~/fe-genmedia-kiosk/backend
gcloud run deploy fe-kiosk-backend \
--source . \
--region us-central1 \
--allow-unauthenticated \
--port 8080
2. สร้างและติดตั้งใช้งานคอนเทนเนอร์ฟรอนท์เอนด์
เมื่อแบ็กเอนด์เสร็จแล้ว ให้บันทึก URL ของแบ็กเอนด์ เขียนลงใน .env.production โดยตรง แล้วทำให้ฟรอนท์เอนด์ใช้งานได้
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. 🏁 ทดสอบและสัมผัสประสบการณ์การใช้งานคีออสก์ AI ของ Formula E
- เปิด
$FRONTEND_URLใน Google Chrome - อัปโหลดรูปเซลฟีหรือรูปภาพบุคคล
- เลือกสถานการณ์การแข่งรถ (Podium Winner Celebration หรือ Pit Lane Gen4 Launch)
- คลิกเริ่มไปป์ไลน์ Generative
- ดูความคืบหน้าแบบเรียลไทม์ในขั้นตอนการทำงานของ AI ทั้ง 3 ขั้นตอน ดังนี้
- Gemini 3.8 Flash: การสร้างพรอมต์ไดรเวอร์
- Nano Banana 2: การแสดงผลอวาตาร์รถแข่งและชุดแข่ง Gen4 ที่กำหนดเอง
- Gemini Omni Flash: การสร้างวิดีโอความละเอียดสูงความยาว 8 วินาทีที่ 60 FPS
- เล่นวิดีโอที่สร้างขึ้นเวอร์ชันสุดท้าย
7. 🎉 สรุปและล้างข้อมูล
สิ่งที่คุณได้เรียนรู้
✅ แพลตฟอร์ม Gemini และ Interactions API: กำลังเริ่มต้น genai.Client(enterprise=True) และเรียกใช้ client.interactions.create()
✅ สคีมาข้อมูลแบบหลายรูปแบบของ Interactions API: การใช้พารามิเตอร์ list-of-dicts input และ generation_config dict
✅ ไปป์ไลน์ AI แบบหลายโมดัล:
gemini-3.8-flash: กำลังสร้างพรอมต์ไดรเวอร์ที่กำหนดเองgemini-3.1-flash-image(Nano Banana 2): อวาตาร์คนขับและการสร้างสติกเกอร์ Gen4gemini-omni-1.1-flash-preview: การสร้างวิดีโอแบบอะซิงโครนัสด้วยการโพลงานเบื้องหลัง
✅ การทำให้ใช้งานได้แบบ Serverless: บริการที่สร้างโดยใช้คอนเทนเนอร์และทำให้ใช้งานได้ใน Google Cloud Run
การล้างทรัพยากร
👉💻 ล้างสภาพแวดล้อม 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