יצירה ופריסה של קיוסק AI למירוצי פורמולה E באמצעות Gemini Omni ו-Cloud Run

1. מבוא

מה תפתחו

‫Formula E AI Kiosk היא חוויה אינטראקטיבית שמבוססת על פלטפורמת Gemini ועל Google GenAI Interactions API. המשתמשים מצלמים את עצמם, בוחרים תרחיש של פורמולה E (למשל, חגיגה על הפודיום או זינוק מרחבת הטיפולים), ו-Gemini הופך אותם לנהגי מרוצים של פורמולה E לצד מכונית מרוץ חשמלית מדור 4.

תרשים פתרון ל-Kiosk Lab

התמקדות בלמידה אינטראקטיבית

ב-Codelab הזה תכתבו את הלוגיקה העיקרית של ה-AI הגנרטיבי באמצעות 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().

אל תדאגו, לא תיתקעו כל תרגיל תכנות כולל בלוק 💡 פתרון והסבר. אם נתקלתם בבעיות, אתם יכולים לגלול כדי לראות את הקוד המלא ואת פרטי הפרמטרים.

שלבי ההגדרה שלא קשורים ל-AI (פיגומים של מעטפת, הגדרות Docker ופריסת Cloud Run) נשארים פקודות פשוטות של העתקה והדבקה, כדי שתוכלו להתמקד בלמידה של Interactions API.

מיתוג ונכסים עיקריים

נכס

תיאור

כתובת אתר מקור

מכונית פורמולה E מדור 4

הפניה רשמית למכונית מרוץ חשמלית מדור 4

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

הלוגו של Formula E ו-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. 🛠️ הגדרת הסביבה והכנת הפרויקט

תיצרו את ספריית סביבת העבודה ואת קובצי הפרויקט ישירות ב-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. הפעלת ממשקי ה-API הנדרשים של 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

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 Service (backend/app/services/gemini_service.py)

👉💻 יוצרים את קובץ השירות לתחילת הדרך שמכיל את הפיגומים ואת הפערים שצריך להשלים באתגרים המעשיים, ומעתיקים ומדביקים את הקוד לתחילת הדרך:

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 ומודלים גנרטיביים

בקטע הזה תכתבו את הלוגיקה העיקרית של ה-AI הגנרטיבי ב-backend/app/services/gemini_service.py באמצעות Interactions API ו-GenerateContent API בפלטפורמת Gemini.

פותחים את backend/app/services/gemini_service.py ב-Cloud Shell Editor. יש ארבעה אתגרים שצריך להשלים.

משימה 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

💡 רוצה רמז או עזרה? גוללים כדי לראות את הפתרון ל-TODO 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. מעבירים רשימה של מילונים אל 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 (gemini-3.1-flash-image דרך GenerateContent API)

משלימים את TODO 3 ב-generate_driver_avatar(). מפעילים את client.models.generate_content() עם gemini-3.1-flash-image (Nano Banana 2) כדי לרנדר את סמל הרכב של נהג הפורמולה 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')

💡 רוצה רמז או עזרה? גוללים כדי לראות את הפתרון ל-TODO 3

    
    response = client.models.generate_content(
      model="gemini-3.1-flash-image", # Or "gemini-nano-banana-2.1"
      content=[text_parts, image_parts, gen4_image_parts],
      config=types.GenerateContentConfig(
        response_modalities=["IMAGE"],
        image_config=types.ImageConfig(aspect_ratio="16:9"),
        )
      )

אתגר 4: יצירת סרטונים באמצעות 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)

4. ‫🏗️ הרכבת האפליקציה הראשית וה-Frontend

עכשיו יוצרים את שאר קובצי ה-wrapper של האפליקציה, נקודות הקצה ל-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. קובץ Docker לקצה העורפי (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)

👉💻 יוצרים את 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 תצורת ה-build של 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 ממשק משתמש של קיוסק אינטראקטיבי (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 הגדרת ה-build של קונטיינר החזית (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. בנייה ופריסה של קונטיינר ממשק משתמש

אחרי שה-backend מוכן, צריך לתעד את כתובת ה-URL שלו, לכתוב אותה ישירות ב-‎ .env.production ולפרוס את ה-frontend

cd ~/fe-genmedia-kiosk/frontend

# 1. Fetch Backend URL
export BACKEND_URL=$(gcloud run services describe fe-kiosk-backend \
  --region us-central1 \
  --format="value(status.url)")

echo "Configuring frontend with Backend URL: $BACKEND_URL"

# 2. Write it into the Vite production environment file
echo "VITE_API_BASE=${BACKEND_URL}" > .env.production

# 3. Deploy frontend (Vite will read .env.production during build)
gcloud run deploy fe-kiosk-frontend \
  --source . \
  --region us-central1 \
  --allow-unauthenticated \
  --port 8080

6. 🏁 בדיקה והתנסות בעמדת ה-AI של פורמולה E

  1. פותחים את $FRONTEND_URL ב-Google Chrome.
  2. מעלים תמונת סלפי או תמונת דיוקן.
  3. בוחרים את תרחיש המרוץ (חגיגת הזכייה בפודיום או השקת הדור הרביעי של רחבת הטיפולים).
  4. לוחצים על START GENERATIVE PIPELINE.
  5. אפשר לעקוב אחרי ההתקדמות בזמן אמת בכל שלושת השלבים של ה-AI:
    • ‫Gemini 3.8 Flash: יצירת הנחיות לנהגים.
    • ‫Nano Banana 2: עיבוד של אווטארים של מכוניות וחליפות מרוצים בהתאמה אישית מדור 4.
    • ‫Gemini Omni Flash: יצירת סרטונים באיכות HD באורך 8 שניות ובקצב של 60 פריימים לשנייה.
  6. מפעילים את הסרטון שנוצר.

7. 🎉 סיכום וניקוי

מה למדתם

‫✅ פלטפורמת Gemini ו-Interactions API: הפעלה של genai.Client(enterprise=True) וקריאה ל-client.interactions.create().

‫✅ סכימת נתונים מולטימודליים של Interactions API: שימוש בפרמטר dict של input ו-generation_config ברשימת מילונים.

‫✅ Multimodal AI Pipeline:

  • ‫gemini-3.8-flash: יצירת הנחיות מותאמות אישית לנהגים.
  • ‫gemini-3.1-flash-image (Nano Banana 2): יצירת אווטאר של נהג ועיצוב של דור 4.
  • ‫gemini-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