ساختن و مستقر کردن کیوسک هوش مصنوعی «فرمول E» با Gemini Omni و Cloud Run

۱. مقدمه

آنچه خواهید ساخت

Formula E AI Kiosk یک تجربه تعاملی است که توسط پلاتفرم Gemini و Google GenAI Interactions API پشتیبانی می‌شود. کاربران عکسی از خودشان می‌گیرند، یک سناریو فرمول E (مانند جشن گرفتن روی سکو یا شروع از خط پیت) را انتخاب می‌کنند، و به Gemini اجازه می‌دهند تا آن‌ها را به یک راننده مسابقه فرمول E در کنار یک خودرو مسابقه الکتریکی Gen4 تبدیل کند.

نمودار راه‌حل برای «آزمایشگاه کیوسک»

تمرکز یادگیری تعاملی

در این codelab، منطق اصلی هوش مصنوعی زایا را بااستفاده از Interactions API در پلاتفرم Gemini (enterprise=True) خواهید نوشت. شکاف‌های کلیدی کد را برای موارد زیر پر خواهید کرد:

  1. مقداردهی اولیه کارخواه: مقداردهی اولیه genai.Client(enterprise=True) برای پلاتفرم Gemini.
  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().

نگران گیر کردن نباشید! هر تمرین کدنویسی شامل یک بلوک 💡 راه‌حل و توضیح است. اگر با مشکلی مواجه شدید، پیمایش کنید تا کد کامل و جزئیات پارامتر را ببینید.

مراحل راه‌اندازی غیرهوش مصنوعی (داربست پوسته، پیکربندی‌های Docker، و استقرار Cloud Run) به‌صورت فرمان‌های کپی و جای‌گذاری ساده باقی می‌مانند تا بتوانید روی تسلط بر Interactions API تمرکز کنید.

دارایی‌ها و نمانام‌سازی اصلی

دارایی

شرح

نشانی وب منبع

Gen4 Formula E Car

مرجع رسمی خودرو مسابقه‌ای تمام‌برقی 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

۲. 🛠️ راه‌اندازی محیط و داربست پروژه

بااستفاده از فرمان‌های CLI مدولار، دایرکتوری فضای کاری و فایل‌های پروژه را مستقیماً در Cloud Shell خواهید ساخت.

۱. باز کردن Cloud Shell و ایجاد دایرکتوری پروژه

👉 ویرایشگر Cloud Shell را در مرورگرتان باز کنید.

👉💻 ساختار فضای کاری را ایجاد کنید:

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

۲. فعال کردن میاناهای برنامه‌سازی کاربردی Google Cloud موردنیاز

👉💻 شناسه پروژه فعلی‌تان را تنظیم کنید و سرویس‌های لازم را فعال کنید:

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

۳. ایجاد حساب سرویس و پیکربندی اجازه‌های 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

۴. بارگیری دارایی‌های نمانام 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

۵. ایجاد پیکربندی محیط و وابستگی‌ها

👉💻 ایجاد .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

‫۶. Seed Starter Gemini Service (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)

۳. ⚡ پیاده‌سازی Interactions API و مدل‌های زایا

در این بخش، منطق اصلی هوش مصنوعی زایا را در backend/app/services/gemini_service.py بااستفاده از Interactions API و GenerateContent API در Gemini Platform خواهید نوشت.

backend/app/services/gemini_service.py را در «ویرایشگر Cloud Shell» باز کنید. چهار شکاف چالش TODO برای تکمیل کردن خواهید دید.

چالش ۱: مقداردهی اولیه مشتری 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

💡 به راهنمایی نیاز دارید یا گیر کرده‌اید؟ برای دیدن «راه‌حل» برای TODO 1 پیمایش کنید

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

چالش ۲: گسترش پیام‌واره Driver با gemini-3.8-flash

TODO 2 را در generate_driver_prompt() تکمیل کنید. از 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
        }
    )

چالش ۳: تولید چهرک با 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')

💡 به راهنمایی نیاز دارید یا گیر کرده‌اید؟ برای دیدن راه‌حل «باید انجام شود» ۳ پیمایش کنید

    
    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"),
        )
      )

چالش ۴: تولید ویدیو با Gemini Omni 1.1 Flash

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

💡 به راهنمایی نیاز دارید یا گیر کرده‌اید؟ برای ازهم باز کردن «راه‌حل برای 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)

۴. 🏗️ درحال مونتاژ کردن برنامه اصلی و میانای کاربری

اکنون فایل‌های پوشاننده برنامه، نقاط پایانی API، و میانای React باقی‌مانده را ایجاد کنید.

[!مهم] مطمئن شوید که همه فرمان‌های ایجاد فایل در این بخش را از فهرست راهنمای فضای کاری ریشه (~/fe-genmedia-kiosk) اجرا می‌کنید.

👉💻 به دایرکتوری ریشه پروژه بروید:

cd ~/fe-genmedia-kiosk

۱. برنامه 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")
    )

۲. Backend 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

۳. پیکربندی و برنامه React پیش‌نما

پیکربندی پیشانه و فایل‌های منبع را گام‌به‌گام ایجاد کنید تا مطمئن شوید هر فایل به‌طور تمیز در Cloud Shell بدون مشکلات میان‌گیر جای‌گذاری تولید می‌شود.

‫۳.۱ مانیفست بسته (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

‫۳.۲ پیکربندی 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

‫۳.۳ پیکربندی ساخت 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

‫۳.۴ نقطه ورودی 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

‫۳.۵ برگه سبک نگارش نمانام (frontend/src/index.css)

👉💻 ایجاد زمینه سفارشی حالت تاریک «فرمول ای»:

cat << 'EOF' > frontend/src/index.css
@font-face {
  font-family: 'Google Sans';
  src: url('/fonts/GoogleSans-Regular.ttf') format('truetype');
  font-weight: 400;
}

@font-face {
  font-family: 'Google Sans';
  src: url('/fonts/GoogleSans-Medium.ttf') format('truetype');
  font-weight: 500;
}

body {
  margin: 0;
  font-family: 'Google Sans', sans-serif;
  background-color: #0b0e14;
  color: #ffffff;
}

.fe-container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
  text-align: center;
}

.fe-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 2rem;
  background: rgba(15, 23, 42, 0.8);
  border-bottom: 2px solid #00e5ff;
}

.fe-btn {
  background: linear-gradient(135deg, #00e5ff 0%, #0066ff 100%);
  color: #000;
  font-weight: bold;
  padding: 0.8rem 2rem;
  border: none;
  border-radius: 30px;
  cursor: pointer;
  font-size: 1.1rem;
  transition: all 0.2s ease;
}

.fe-btn:hover:not(:disabled) {
  transform: translateY(-2px);
  box-shadow: 0 4px 15px rgba(0, 229, 255, 0.4);
}

.fe-btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.fe-btn-secondary {
  background: rgba(30, 41, 59, 0.8);
  color: #00e5ff;
  border: 1px solid #00e5ff;
  font-weight: bold;
  padding: 0.6rem 1.6rem;
  border-radius: 30px;
  cursor: pointer;
  font-size: 1rem;
  transition: all 0.2s ease;
}

.fe-btn-secondary:hover:not(:disabled) {
  background: rgba(0, 229, 255, 0.15);
  box-shadow: 0 0 10px rgba(0, 229, 255, 0.3);
}

.fe-card {
  background: #151c28;
  border: 1px solid #1e293b;
  border-radius: 16px;
  padding: 2rem;
  margin: 1.5rem auto;
  max-width: 720px;
}

.fe-tabs {
  display: flex;
  justify-content: center;
  gap: 1rem;
  margin-bottom: 1.5rem;
}

.fe-tab-btn {
  background: #0f172a;
  border: 1px solid #334155;
  color: #94a3b8;
  font-size: 1rem;
  font-weight: 500;
  padding: 0.6rem 1.4rem;
  border-radius: 20px;
  cursor: pointer;
  transition: all 0.2s ease;
}

.fe-tab-btn.active {
  background: #1e293b;
  border-color: #00e5ff;
  color: #00e5ff;
  box-shadow: 0 0 10px rgba(0, 229, 255, 0.2);
}

.fe-camera-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  margin: 1.5rem auto;
}

.fe-camera-feed-box {
  position: relative;
  width: 100%;
  max-width: 480px;
  aspect-ratio: 4/3;
  background: #0b0e14;
  border-radius: 12px;
  overflow: hidden;
  border: 2px solid #334155;
  display: flex;
  align-items: center;
  justify-content: center;
}

.fe-video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scaleX(-1);
}

.fe-captured-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 12px;
}

.fe-alert {
  background: rgba(239, 68, 68, 0.15);
  border: 1px solid #ef4444;
  color: #fca5a5;
  padding: 0.8rem 1.2rem;
  border-radius: 8px;
  margin: 1rem auto;
  max-width: 480px;
  font-size: 0.95rem;
}

.fe-file-picker {
  margin: 1.5rem 0;
}

.fe-file-picker input[type="file"] {
  background: #0f172a;
  border: 1px dashed #334155;
  padding: 1.5rem;
  border-radius: 12px;
  color: #94a3b8;
  width: 80%;
  max-width: 400px;
  cursor: pointer;
}

.fe-file-picker input[type="file"]:hover {
  border-color: #00e5ff;
  color: #ffffff;
}
EOF

‫3.6 React Root Component (frontend/src/main.tsx)

👉💻 فایل راه‌انداز برنامه 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

‫۳.۷ واسط کاربر کیوسک تعاملی (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>
  );
}

‫۳.۸ پیکربندی سرور 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

۵. 🚀 مستقر کردن در Cloud Run

۱. ساخت و استقرار محفظه باطن

👉💻 سرور زیرینه را در Cloud Run مستقر کنید:

cd ~/fe-genmedia-kiosk/backend

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

۲. ساخت و استقرار محفظه پیش‌نما

وقتی زیرینه تمام شد، نشانی وب آن را ضبط کنید، آن را مستقیماً در 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

‫۶. 🏁 آزمایش و تجربه «کیوسک هوش مصنوعی فرمول ای»

  1. ‫$FRONTEND_URL در Google Chrome باز می‌شود.
  2. عکس سلفی یا چهره بارگذاری کنید.
  3. سناریو مسابقه خود را انتخاب کنید (جشن برنده سکو یا راه‌اندازی نسل ۴ پیت لین).
  4. روی شروع خط تولید زایا کلیک کنید.
  5. پیشرفت هم‌زمان را در هر سه مرحله هوش مصنوعی تماشا کنید:
    • Gemini 3.8 Flash: تولید پیام‌واره درایور.
    • Nano Banana 2: رندر کردن چهرک لباس مسابقه و خودرو Gen4 سفارشی.
    • ‫Gemini Omni Flash: تولید ویدیو HD با نرخ ۶۰ فریم در ثانیه و مدت ۸ ثانیه.
  6. ویدیو زایای نهایی‌تان را بازپخش کنید!

‫۷. 🎉 نتیجه‌گیری و پاک‌سازی

آنچه آموخته‌اید

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

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

✅ خط لوله هوش مصنوعی چندحالتی:

  • ‫gemini-3.8-flash: درحال تولید پیام‌واره‌های سفارشی‌سازی‌شده برای راننده.
  • gemini-3.1-flash-image (Nano Banana 2): Driver avatar & Gen4 livery creation.
  • ‫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