Gemini Omni 및 Cloud Run으로 포뮬러 E AI 키오스크 빌드 및 배포

1. 소개

빌드할 항목

Formula E AI 키오스크는 Gemini 플랫폼과 Google 생성형 AI Interactions API를 기반으로 하는 대화형 환경입니다. 사용자가 자신의 사진을 찍고 포뮬러 E 시나리오 (예: 시상대에서 축하하기, 피트 레인에서 출발하기)를 선택하면 Gemini가 사용자를 4세대 전기 경주용 자동차와 함께 포뮬러 E 레이스 드라이버로 변신시켜 줍니다.

키오스크 실습 솔루션 다이어그램

대화형 학습 중점

이 Codelab에서는 Gemini Platform (enterprise=True)의 Interactions API를 사용하여 핵심 생성형 AI 로직을 작성합니다. 다음을 위해 주요 코드 공백을 채웁니다.

  1. 클라이언트 초기화: Gemini Platform용 genai.Client(enterprise=True) 초기화
  2. Gemini 3.8 Flash: 사전 기반 멀티모달 입력 ([{"type": "text", ...}, {"type": "image", ...}])과 generation_config={"thinking_level": "high", "temperature": 0.2}를 사용하여 사용자 사진에서 풍부한 프롬프트를 생성합니다.
  3. Nano Banana 2/2.1 (gemini-3.1-flash-image or gemini-nano-banana-2.1): 확장된 프롬프트를 사용한 이미지 투 이미지 차량 아이콘 및 Gen4 리버리 생성
  4. Gemini Omni 1.1 Flash (gemini-omni-1.1-flash-preview): background=True 및 폴링 client.interactions.get()을 사용한 비동기 동영상 생성

막히더라도 걱정하지 마세요 각 코딩 연습에는 💡 솔루션 및 설명 블록이 포함되어 있습니다. 문제가 발생하면 스크롤하여 전체 코드와 매개변수 세부정보를 확인하세요.

AI 이외의 설정 단계 (셸 스캐폴딩, Docker 구성, Cloud Run 배포)는 간소화된 복사 및 붙여넣기 명령어로 유지되므로 Interactions API를 마스터하는 데 집중할 수 있습니다.

핵심 브랜딩 및 애셋

확장 소재

설명

소스 URL

4세대 포뮬러 E 자동차

공식 Gen4 완전 전기 레이싱카 참조

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

Formula E x Google Cloud 로고

공동 브랜드 투명 로고

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

Google Sans Regular 글꼴

공식 브랜드 타이포그래피

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

Google Sans Medium 글꼴

공식 브랜드 타이포그래피

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

2. 🛠️ 환경 설정 및 프로젝트 스캐폴딩

모듈식 CLI 명령어를 사용하여 Cloud Shell에서 직접 작업공간 디렉터리와 프로젝트 파일을 빌드합니다.

1. Cloud Shell 열기 및 프로젝트 디렉터리 만들기

👉 브라우저에서 Cloud Shell 편집기를 엽니다.

👉💻 작업공간 구조 만들기:

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

2. 필수 Google Cloud API 사용 설정

👉💻 현재 프로젝트 ID를 설정하고 필요한 서비스를 사용 설정합니다.

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. 포뮬러 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 및 생성 모델 구현

이 섹션에서는 Gemini Platform의 Interactions API 및 GenerateContent API를 사용하여 backend/app/services/gemini_service.py에서 핵심 생성형 AI 로직을 작성합니다.

Cloud Shell 편집기에서 backend/app/services/gemini_service.py를 엽니다. 완료해야 하는 TODO 챌린지 공백이 4개 있습니다.

과제 1: Gemini Enterprise 클라이언트 초기화하기

backend/app/services/gemini_service.py를 엽니다. Gemini Enterprise (enterprise=True)에 구성된 genai.Client를 초기화하여 TODO 1을 완료합니다.

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

💡 힌트가 필요하거나 막히는 부분이 있나요? 스크롤하여 TODO 1의 솔루션 확인

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

과제 2: gemini-3.8-flash를 사용한 드라이버 프롬프트 확장

generate_driver_prompt()에서 TODO 2를 완료합니다. gemini-3.8-flash를 사용하여 운전자 사진을 분석하고 Nano Banana 2를 위한 자세한 프롬프트를 작성합니다. 딕셔너리 목록을 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

💡 힌트가 필요하거나 막히는 부분이 있나요? 클릭하여 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
        }
    )

과제 3: Nano Banana 2를 사용한 아바타 생성 (GenerateContent API를 통한 gemini-3.1-flash-image)

generate_driver_avatar()에서 TODO 3을 완료합니다. gemini-3.1-flash-image (Nano Banana 2)를 사용하여 client.models.generate_content()를 호출해 스타일이 지정된 포뮬러 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를 사용한 동영상 생성

generate_formula_e_video()에서 TODO 4를 완료합니다. background=True를 사용하여 gemini-omni-1.1-flash-preview와 비동기 동영상 상호작용을 시작하고, 상태가 '완료'될 때까지 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)

💡 힌트가 필요하거나 막히는 부분이 있나요? 클릭하여 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. 🏗️ 기본 애플리케이션 및 프런트엔드 조립

이제 나머지 애플리케이션 래퍼 파일, API 엔드포인트, React 인터페이스를 만듭니다.

[!중요] 이 섹션의 모든 파일 생성 명령어는 루트 작업공간 디렉터리 (~/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 패키지 매니페스트 (frontend/package.json)

👉💻 프런트엔드 종속 항목 정의를 만듭니다.

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)

👉💻 TypeScript 컴파일러 (tsc)가 프로젝트를 검증하고 빌드할 수 있도록 frontend/tsconfig.json를 만듭니다.

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 루트 구성요소 (frontend/src/main.tsx)

👉💻 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. 🏁 포뮬러 E AI 키오스크 테스트 및 체험

  1. Chrome에서 $FRONTEND_URL 주소를 엽니다.
  2. 셀카 또는 인물 사진을 업로드합니다.
  3. 레이싱 시나리오 (Podium Winner Celebration 또는 Pit Lane Gen4 Launch)를 선택합니다.
  4. 생성 파이프라인 시작을 클릭합니다.
  5. 세 가지 AI 단계의 실시간 진행 상황을 확인하세요.
    • Gemini 3.8 Flash: 드라이버 프롬프트 생성
    • Nano Banana 2: 맞춤 Gen4 자동차 및 레이싱 슈트 아바타 렌더링
    • Gemini Omni Flash: 8초 분량의 60fps HD 동영상 생성
  6. 최종 생성형 동영상을 재생합니다.

7. 🎉 결론 및 정리

학습한 내용

✅ Gemini Platform 및 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): 드라이버 아바타 및 Gen4 리버리 생성
  • gemini-omni-1.1-flash-preview: 백그라운드 태스크 폴링을 사용한 비동기 동영상 생성

✅ 서버리스 배포: 서비스를 컨테이너화하여 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