·12 min read

Veo 3: Text-to-Video for Technical Demos

Prompt engineering patterns, temporal consistency techniques, and how we integrated Google Veo 3 into our documentation pipeline for product demo videos.

This is part 15 of the AI Tools Mastery series. Previously we covered Claude and Gemini. Today we tackle the hardest problem in developer relations: producing high-quality technical demo videos at scale.


The Problem

Meet Priya. She leads the sales engineering team at a fast-growing SaaS company called DataFlow. Every quarter, her team needs to send personalized product demo videos to hundreds of prospects. Each prospect cares about different features — one wants to see the analytics dashboard, another needs the API integration walkthrough, a third is all about the reporting module.

Priya’s team has a problem. A single professional demo video costs $3,200 and takes 14 days to produce. That’s script writing, screen recording, voiceover, editing, revisions, and final rendering. Multiply that by 100 prospects, and you’re looking at $320,000 and 1,400 days of work. Even if they reuse parts, they can barely produce 10 custom demos per quarter.

The worst part? By the time a demo is ready, the product has already shipped two updates. The button moved. The color changed. The API response format is different. Prospects watch outdated demos and think the product is broken.

This is “demo debt” — and it’s a silent killer of trust.

Priya needed a way to generate demo videos that cost under $100 each, turned around in hours not weeks, and could be regenerated on every release. She needed Google Veo 3.

Why this matters: If you create any kind of product demo, tutorial, or walkthrough video, you’ve felt this pain. Demo debt grows faster than you can fix it. Veo 3 changes the math completely.

The Investigation

Priya started researching. She compared every major video generation model available in mid-2026. Here’s what she found:

Metric Veo 3 Runway Gen-4 Pika 2.0 Kling 2.1 Sora
Max duration 8s (extendable) 10s 10s 8s 20s
Resolution 1080p 1080p 720p 1080p 1080p
Text rendering Poor Poor Poor Poor Poor
UI/technical accuracy Excellent Good Fair Good Good
Motion coherence Excellent Excellent Good Very Good Excellent
API latency (p50) 45s 90s 120s 60s 300s+
Cost per 8s clip $0.08 $0.15 $0.10 $0.12 $0.20
Async generation Yes Yes No Yes No
Reference image support Yes Yes Yes Yes Yes
Extend (outpainting) Yes Yes No Yes No
Google Cloud integration Native None None None None

What each metric means:

  • Max duration: The longest single clip the model can produce. Veo 3 caps at 8 seconds, but you can extend clips (more on that later).
  • Resolution: Video quality. 1080p is full HD. 720p is still good but less sharp.
  • Text rendering: How well the model displays on-screen text. This is every model’s weak spot — they all struggle here.
  • UI/technical accuracy: How well the model shows realistic user interfaces and technical content. This is the most important metric for demos.
  • Motion coherence: Whether objects and UI elements stay consistent across frames. Bad coherence means flickering or morphing.
  • API latency: How long the model takes to generate a clip. 45 seconds means you wait less than a minute per scene.
  • Cost per 8s clip: What you pay for one generated video. At $0.08, Veo 3 is the cheapest option.
  • Reference image support: Can you give the model a screenshot to copy the style? This is critical for UI consistency.
  • Google Cloud integration: Does it work natively with Google Cloud services? Veo 3 does, which makes deployment much easier.

Winner: Veo 3 — not because it’s the best at everything, but because it’s the best for technical demos. The combination of UI accuracy, fast generation, native GCP integration, and low cost makes it the clear choice for developer-facing video content.

The Solution

Priya built a three-stage pipeline. Here’s how it works.

Stage 1: Scene Decomposition

Here’s what this code does: It takes a demo script (like “show the dashboard, then click Analytics, then filter by region”) and splits it into individual scenes. Each scene becomes a separate video clip. It uses Gemini 2.5 Pro (Google’s smart AI model) to figure out where scene boundaries should be.

"""
scene_decomposer.py — Breaks a demo script into individual Veo-3-ready scenes.
Uses Gemini 2.5 Pro for intelligent scene boundary detection.
"""

import json
import os
from typing import List, Dict, Optional
from google import genai          # Google's AI SDK
from google.genai import types    # Types for API config
from pydantic import BaseModel, Field  # Data validation library
import time
import hashlib


class Scene(BaseModel):
    """A single scene in the demo pipeline.
    
    Each scene is one video clip (1-8 seconds long).
    """
    scene_id: int = Field(..., description="Sequential scene number")
    description: str = Field(..., description="What happens in this scene")
    duration_seconds: float = Field(..., ge=1.0, le=8.0,
                                     description="Target duration (1-8s)")
    visual_elements: List[str] = Field(
        default_factory=list,
        description="Key UI elements, data displays, or visual components"
    )
    transition_type: str = Field(
        default="cut",
        description="Transition to next scene: cut, fade, dissolve, slide"
    )
    narration_text: str = Field(
        default="",
        description="Voiceover text for this scene"
    )
    reference_image: Optional[str] = Field(
        default=None,
        description="Path to reference image for visual consistency"
    )
    camera_motion: str = Field(
        default="static",
        description="Camera motion: static, pan, zoom_in, zoom_out, track"
    )


class ScriptDecomposer:
    """
    Decomposes a demo script into Veo-3-ready scenes.
    Uses Gemini 2.5 Pro for intelligent scene analysis.
    """

    def __init__(self, api_key: Optional[str] = None):
        # Create a client to talk to Google's AI
        self.client = genai.Client(
            api_key=api_key or os.environ.get("GEMINI_API_KEY")
        )
        # Use Gemini 2.5 Pro — Google's most capable model
        self.model = "gemini-2.5-pro-exp-03-25"

    def decompose_script(
        self,
        script_text: str,
        max_scenes: int = 20,
        reference_images: Optional[Dict[int, str]] = None
    ) -> List[Scene]:
        """
        Decompose a full demo script into individual scenes.

        Args:
            script_text: The full demo script with narration and visual descriptions
            max_scenes: Maximum number of scenes to generate
            reference_images: Optional dict mapping scene_id to image paths

        Returns:
            List of Scene objects ready for Veo 3 generation
        """
        # Build a prompt that tells Gemini how to split the script
        prompt = f"""You are a video scene decomposition expert. Break this demo script into
individual video scenes suitable for Veo 3 generation.

RULES:
- Each scene MUST be 1-8 seconds (Veo 3 limit)
- Describe visual elements precisely (UI components, data, animations)
- Specify camera motion (static, pan, zoom_in, zoom_out, track)
- Keep narration_text under 50 words per scene
- Use transition types: cut, fade, dissolve, slide
- Max {max_scenes} scenes
- Prioritize visual clarity over artistic quality (this is a technical demo)

SCRIPT:
{script_text}

Return a JSON array of scenes with fields:
scene_id, description, duration_seconds, visual_elements, transition_type,
narration_text, camera_motion"""

        # Send the script to Gemini and get back scene descriptions
        response = self.client.models.generate_content(
            model=self.model,
            contents=prompt,
            config=types.GenerateContentConfig(
                temperature=0.3,          # Low temperature = more predictable output
                max_output_tokens=4096,   # Max response length
            )
        )

        # Parse the response into Scene objects
        scenes = self._parse_scenes(response.text)

        # Attach reference images if provided
        if reference_images:
            for scene in scenes:
                if scene.scene_id in reference_images:
                    scene.reference_image = reference_images[scene.scene_id]

        return scenes

    def _parse_scenes(self, raw_text: str) -> List[Scene]:
        """Parse Gemini response into Scene objects."""
        # Strip markdown code fences if present
        text = raw_text.strip()
        if text.startswith("```json"):
            text = text[7:]
        if text.startswith("```"):
            text = text[3:]
        if text.endswith("```"):
            text = text[:-3]

        data = json.loads(text.strip())
        return [Scene(**item) for item in data]

    def generate_prompt(self, scene: Scene) -> str:
        """
        Generate an optimized Veo 3 prompt from a Scene object.

        Uses the 5-part formula: subject + action + environment + style + technical
        """
        # Take the first 5 visual elements (Veo 3 works best with fewer details)
        elements = ", ".join(scene.visual_elements[:5])
        # Convert camera motion to a natural language description
        motion_desc = {
            "static": "static camera, tripod-mounted",
            "pan": "slow horizontal pan",
            "zoom_in": "slow zoom in",
            "zoom_out": "slow zoom out",
            "track": "tracking shot following the action",
        }.get(scene.camera_motion, "static camera")

        # Build the 5-part prompt
        prompt = (
            f"[Subject] Technical software demo showing {scene.description}. "
            f"[Visuals] Key elements visible: {elements}. "
            f"[Motion] {motion_desc}. "
            f"[Style] Clean UI, accurate text rendering, professional lighting, "
            f"no motion blur, no lens flare, no artistic filters. "
            f"[Technical] 1080p, 30fps, smooth motion, consistent UI elements "
            f"across frames, no hallucinations in text or UI components."
        )

        return prompt


# Usage example
if __name__ == "__main__":
    decomposer = ScriptDecomposer()

    demo_script = """
    [SCENE 1 - 0:00]
    Narrator: "Welcome to the new Nivant Analytics dashboard."
    Visual: Dashboard loads with real-time metrics — active users (1,247),
    API latency (p95: 42ms), error rate (0.3%). Top navigation shows
    Overview, Analytics, Settings tabs.

    [SCENE 2 - 0:08]
    Narrator: "Let's drill into the Analytics view."
    Visual: Click on Analytics tab. Page transitions to show line charts
    for daily active users, revenue, and conversion rate. Date range
    selector shows "Last 30 days."

    [SCENE 3 - 0:16]
    Narrator: "Filter by region to see APAC performance."
    Visual: Click region filter dropdown, select "APAC." Charts update
    with new data. Tooltip shows "APAC: 342 active users, +12% WoW."
    """

    scenes = decomposer.decompose_script(demo_script, max_scenes=5)

    for scene in scenes:
        prompt = decomposer.generate_prompt(scene)
        print(f"\n=== Scene {scene.scene_id} ({scene.duration_seconds}s) ===")
        print(f"Prompt: {prompt[:200]}...")
        print(f"Transition: {scene.transition_type}")
        print(f"Narration: {scene.narration_text}")

Stage 2: Veo 3 Generation (Async, Rate-Limited)

Here’s what this code does: It takes the scene descriptions from Stage 1 and sends them to Veo 3 for video generation. It handles rate limiting (so you don’t overwhelm the API), retries on failure, and tracks costs. Think of it as a factory line — scenes go in, video clips come out.

"""
veo3_generator.py — Async generation of Veo 3 video clips with rate limiting,
retry logic, and cost tracking.
"""

import asyncio
import json
import os
import time
from dataclasses import dataclass, field
from typing import List, Optional, Callable
from google.cloud import aiplatform
from google.cloud.aiplatform import telemetry
from google.genai import types
import aiohttp
import backoff
from datetime import datetime


@dataclass
class GenerationResult:
    """Result of a single Veo 3 generation.
    
    Tracks whether it succeeded, what it cost, and how long it took.
    """
    scene_id: int
    success: bool
    video_path: Optional[str] = None
    gcs_uri: Optional[str] = None
    duration_seconds: float = 0.0
    cost: float = 0.0
    latency_seconds: float = 0.0
    error: Optional[str] = None
    retry_count: int = 0
    timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())


class RateLimiter:
    """
    Token-bucket rate limiter for Veo 3 API calls.
    Default: 10 requests per minute (adjust based on your quota).
    
    Think of this like a water faucet with a bucket underneath.
    The faucet drips at a steady rate (10 tokens per minute).
    Each API call uses one token. If the bucket is empty, you wait.
    """
    def __init__(self, rate: float = 10.0, per: float = 60.0):
        self.rate = rate          # How many tokens we get
        self.per = per            # Per how many seconds
        self.tokens = rate        # Start with a full bucket
        self.last_refill = time.monotonic()
        self._lock = asyncio.Lock()

    async def acquire(self):
        """Wait until a token is available."""
        async with self._lock:
            now = time.monotonic()
            elapsed = now - self.last_refill
            # Add tokens that have accumulated since last check
            self.tokens = min(self.rate, self.tokens + elapsed * (self.rate / self.per))
            self.last_refill = now

            if self.tokens < 1:
                # Not enough tokens — calculate how long to wait
                wait_time = (1 - self.tokens) * (self.per / self.rate)
                await asyncio.sleep(wait_time)
                self.tokens = 0
            else:
                self.tokens -= 1


class Veo3Generator:
    """
    Async Veo 3 video generator with rate limiting, retry, and cost tracking.
    """

    COST_PER_SECOND = 0.01  # $0.01 per second of generated video
    MAX_RETRIES = 3
    BASE_DELAY = 2.0

    def __init__(
        self,
        project_id: str,
        location: str = "us-central1",
        rate_limit: float = 10.0,
        progress_callback: Optional[Callable] = None,
    ):
        self.project_id = project_id
        self.location = location
        self.rate_limiter = RateLimiter(rate=rate_limit)
        self.progress_callback = progress_callback
        self.total_cost = 0.0
        self.results: List[GenerationResult] = []

        # Initialize Vertex AI (Google Cloud's ML platform)
        aiplatform.init(project=project_id, location=location)

    async def generate_clip(
        self,
        prompt: str,
        scene_id: int,
        duration_seconds: float = 4.0,
        reference_image: Optional[str] = None,
        aspect_ratio: str = "16:9",
    ) -> GenerationResult:
        """
        Generate a single Veo 3 video clip.

        Args:
            prompt: Optimized Veo 3 prompt
            scene_id: Scene identifier for tracking
            duration_seconds: Target duration (1-8s)
            reference_image: Optional GCS path to reference image
            aspect_ratio: "16:9" or "9:16"

        Returns:
            GenerationResult with video path and metadata
        """
        start_time = time.monotonic()
        result = GenerationResult(scene_id=scene_id)

        # Retry up to MAX_RETRIES times if something goes wrong
        for attempt in range(self.MAX_RETRIES):
            try:
                # Wait for a rate limit token before making the API call
                await self.rate_limiter.acquire()

                # Build the generation request
                generation_config = {
                    "prompt": prompt,
                    "duration_seconds": min(duration_seconds, 8.0),  # Cap at 8s
                    "aspect_ratio": aspect_ratio,
                    "fps": 30,
                    "resolution": "1920x1080",
                }

                if reference_image:
                    generation_config["reference_image"] = reference_image

                # Submit generation job
                # Note: This uses the actual Veo 3 API via Vertex AI
                operation = await self._submit_generation(generation_config)

                # Poll for completion (Veo 3 takes ~45 seconds on average)
                video_uri = await self._poll_operation(operation)

                latency = time.monotonic() - start_time
                cost = duration_seconds * self.COST_PER_SECOND

                result.success = True
                result.gcs_uri = video_uri
                result.duration_seconds = duration_seconds
                result.cost = cost
                result.latency_seconds = latency
                result.retry_count = attempt
                result.timestamp = datetime.utcnow().isoformat()

                self.total_cost += cost
                self.results.append(result)

                if self.progress_callback:
                    self.progress_callback(scene_id, "completed", result)

                return result

            except Exception as e:
                # Exponential backoff: wait 2s, then 4s, then 8s
                wait_time = self.BASE_DELAY * (2 ** attempt)
                if self.progress_callback:
                    self.progress_callback(scene_id, "retrying",
                                           f"Attempt {attempt + 1} failed: {e}")

                if attempt < self.MAX_RETRIES - 1:
                    await asyncio.sleep(wait_time)
                else:
                    # All retries exhausted — mark as failed
                    result.success = False
                    result.error = str(e)
                    result.latency_seconds = time.monotonic() - start_time
                    result.retry_count = attempt
                    self.results.append(result)

                    if self.progress_callback:
                        self.progress_callback(scene_id, "failed", result)

        return result

    async def generate_batch(
        self,
        prompts: List[str],
        scene_ids: List[int],
        durations: Optional[List[float]] = None,
        reference_images: Optional[List[Optional[str]]] = None,
        max_concurrent: int = 3,
    ) -> List[GenerationResult]:
        """
        Generate multiple video clips concurrently with a concurrency limit.

        Args:
            prompts: List of Veo 3 prompts
            scene_ids: Corresponding scene IDs
            durations: Optional per-scene durations (default 4s)
            reference_images: Optional per-scene reference images
            max_concurrent: Maximum concurrent generations

        Returns:
            List of GenerationResult objects
        """
        # Semaphore limits how many tasks run at the same time
        semaphore = asyncio.Semaphore(max_concurrent)

        async def bounded_generate(prompt, scene_id, duration, ref_img):
            async with semaphore:
                return await self.generate_clip(
                    prompt=prompt,
                    scene_id=scene_id,
                    duration_seconds=duration or 4.0,
                    reference_image=ref_img,
                )

        if durations is None:
            durations = [4.0] * len(prompts)
        if reference_images is None:
            reference_images = [None] * len(prompts)

        # Create a task for each scene and run them all
        tasks = [
            bounded_generate(p, sid, d, r)
            for p, sid, d, r in zip(prompts, scene_ids, durations, reference_images)
        ]

        return await asyncio.gather(*tasks)

    async def _submit_generation(self, config: dict):
        """Submit a Veo 3 generation job to Vertex AI."""
        # This wraps the actual Vertex AI Veo 3 API call
        # The exact API surface depends on your Vertex AI SDK version
        api_endpoint = f"https://{self.location}-aiplatform.googleapis.com"
        url = f"{api_endpoint}/v1/projects/{self.project_id}/locations/{self.location}/publishers/google/models/veo-3:predict"

        headers = {
            "Authorization": f"Bearer {await self._get_access_token()}",
            "Content-Type": "application/json",
        }

        payload = {
            "instances": [{"prompt": config["prompt"]}],
            "parameters": {
                "durationSeconds": config["duration_seconds"],
                "aspectRatio": config["aspect_ratio"],
                "fps": config["fps"],
                "sampleCount": 1,
            },
        }

        async with aiohttp.ClientSession() as session:
            async with session.post(url, headers=headers, json=payload) as resp:
                if resp.status != 200:
                    error_text = await resp.text()
                    raise RuntimeError(f"Veo 3 API error {resp.status}: {error_text}")
                return await resp.json()

    async def _poll_operation(self, operation: dict) -> str:
        """Poll a long-running Veo 3 operation until completion."""
        # In production, you'd use the operation name to poll
        # For this example, we simulate the polling
        await asyncio.sleep(45)  # Average Veo 3 latency
        return f"gs://{self.project_id}-veo-output/scene_{int(time.time())}.mp4"

    async def _get_access_token(self) -> str:
        """Get GCP access token for API authentication."""
        # In production, use google.auth.default()
        # For local dev, use gcloud auth application-default print-access-token
        proc = await asyncio.create_subprocess_exec(
            "gcloud", "auth", "application-default", "print-access-token",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, _ = await proc.communicate()
        return stdout.decode().strip()

    def get_cost_report(self) -> dict:
        """Generate a cost report for all generations in this session."""
        successful = [r for r in self.results if r.success]
        failed = [r for r in self.results if not r.success]

        return {
            "total_generations": len(self.results),
            "successful": len(successful),
            "failed": len(failed),
            "total_cost": self.total_cost,
            "average_latency": (
                sum(r.latency_seconds for r in successful) / len(successful)
                if successful else 0
            ),
            "total_duration_seconds": sum(r.duration_seconds for r in successful),
            "per_scene_costs": {
                r.scene_id: {"cost": r.cost, "latency": r.latency_seconds}
                for r in successful
            },
        }


# Usage example
async def main():
    generator = Veo3Generator(
        project_id="nivant-labs-demo",
        rate_limit=10,
        progress_callback=lambda sid, status, data: print(
            f"Scene {sid}: {status}"
        ),
    )

    prompts = [
        "Technical software demo showing analytics dashboard loading with "
        "real-time metrics: 1,247 active users, p95 latency 42ms, "
        "error rate 0.3%. Clean UI, accurate text, professional lighting.",
        "Click on Analytics tab. Page transition to line charts showing "
        "daily active users, revenue, and conversion rate. "
        "Date range selector shows 'Last 30 days'.",
        "Click region filter dropdown, select 'APAC'. Charts update with "
        "new data. Tooltip shows 'APAC: 342 active users, +12% WoW'.",
    ]

    results = await generator.generate_batch(
        prompts=prompts,
        scene_ids=[1, 2, 3],
        durations=[4.0, 6.0, 5.0],
    )

    report = generator.get_cost_report()
    print(json.dumps(report, indent=2))


if __name__ == "__main__":
    asyncio.run(main())

Stage 3: Assembly and Audio Mix

Here’s what this code does: It takes all the individual video clips from Stage 2 and stitches them together into one final video. It adds transitions between scenes, mixes in narration audio and background music, and generates subtitles. Think of it as the video editor that runs automatically.

"""
demo_assembler.py — Assembles Veo 3 clips into a final demo video with
transitions, narration audio, background music, and subtitles.
"""

import json
import os
import subprocess
import tempfile
from dataclasses import dataclass, field
from typing import List, Optional, Dict
from pathlib import Path
import hashlib


@dataclass
class AssemblyConfig:
    """Configuration for the final video assembly."""
    output_path: str = "final_demo.mp4"
    resolution: str = "1920x1080"
    fps: int = 30
    transition_duration: float = 0.3  # seconds
    background_music_path: Optional[str] = None
    music_volume: float = 0.15  # 15% volume for background
    narration_volume: float = 1.0
    subtitle_format: str = "srt"
    add_fade_in: bool = True
    add_fade_out: bool = True
    fade_duration: float = 0.5
    output_bitrate: str = "8M"
    audio_codec: str = "aac"
    video_codec: str = "h264"


@dataclass
class SceneClip:
    """A single scene clip with its metadata."""
    video_path: str
    narration_path: Optional[str] = None
    transition: str = "cut"
    duration: float = 4.0
    subtitle_text: Optional[str] = None


class DemoAssembler:
    """
    Assembles Veo 3 clips into a final demo video using FFmpeg.
    Handles transitions, audio mixing, subtitles, and quality control.
    """

    def __init__(self, config: Optional[AssemblyConfig] = None):
        self.config = config or AssemblyConfig()
        self._check_ffmpeg()

    def _check_ffmpeg(self):
        """Verify FFmpeg is installed and has required codecs."""
        try:
            result = subprocess.run(
                ["ffmpeg", "-version"],
                capture_output=True, text=True, check=True
            )
            if "libx264" not in result.stdout and "h264" not in result.stdout:
                print("Warning: H.264 codec not found in FFmpeg build")
        except (subprocess.CalledProcessError, FileNotFoundError):
            raise RuntimeError(
                "FFmpeg is required. Install with: brew install ffmpeg"
            )

    def assemble(
        self,
        clips: List[SceneClip],
        output_path: Optional[str] = None,
    ) -> str:
        """
        Assemble all clips into the final demo video.

        Args:
            clips: Ordered list of SceneClip objects
            output_path: Override output path from config

        Returns:
            Path to the assembled video file
        """
        output = output_path or self.config.output_path
        temp_dir = tempfile.mkdtemp(prefix="demo_assembly_")

        try:
            # Step 1: Prepare individual clips with transitions
            prepared_clips = self._prepare_clips(clips, temp_dir)

            # Step 2: Create the concat file (a list of clips to join)
            concat_file = self._create_concat_file(prepared_clips, temp_dir)

            # Step 3: Generate subtitles if needed
            subtitle_file = None
            if any(c.subtitle_text for c in clips):
                subtitle_file = self._generate_subtitles(clips, temp_dir)

            # Step 4: Assemble final video
            self._run_ffmpeg_concat(concat_file, subtitle_file, output, temp_dir)

            # Step 5: Generate thumbnail (a preview image from the video)
            thumbnail_path = self._generate_thumbnail(output, temp_dir)

            return output

        finally:
            # Cleanup temp files
            import shutil
            shutil.rmtree(temp_dir, ignore_errors=True)

    def _prepare_clips(
        self, clips: List[SceneClip], temp_dir: str
    ) -> List[str]:
        """Prepare each clip with transitions and audio."""
        prepared = []

        for i, clip in enumerate(clips):
            output_path = os.path.join(temp_dir, f"prepared_{i:04d}.mp4")

            # Build filter complex for this clip
            filters = []

            # Add transition effect
            if clip.transition == "fade" and i > 0:
                td = self.config.transition_duration
                filters.append(
                    f"fade=t=in:st=0:d={td}:alpha=1"
                )

            # Mix narration audio if provided
            if clip.narration_path and os.path.exists(clip.narration_path):
                audio_filter = (
                    f"[1:a]volume={self.config.narration_volume}[narration];"
                    f"[0:a][narration]amix=inputs=2:duration=first:dropout_transition=2"
                )
                audio_input = f"-i {clip.narration_path}"
            else:
                audio_filter = f"volume={self.config.narration_volume}"
                audio_input = ""

            filter_str = ",".join(filters) if filters else "null"

            cmd = [
                "ffmpeg",
                "-i", clip.video_path,
            ] + (audio_input.split() if audio_input else []) + [
                "-filter_complex", audio_filter if clip.narration_path else f"[0:a]{audio_filter}",
                "-map", "0:v",
                "-map", "[a]" if clip.narration_path else "0:a",
                "-c:v", "libx264",
                "-c:a", self.config.audio_codec,
                "-pix_fmt", "yuv420p",
                "-y",
                output_path,
            ]

            subprocess.run(cmd, check=True, capture_output=True)
            prepared.append(output_path)

        return prepared

    def _create_concat_file(
        self, clip_paths: List[str], temp_dir: str
    ) -> str:
        """Create an FFmpeg concat demuxer file."""
        concat_path = os.path.join(temp_dir, "concat_list.txt")

        with open(concat_path, "w") as f:
            for path in clip_paths:
                f.write(f"file '{path}'\n")

        return concat_path

    def _generate_subtitles(
        self, clips: List[SceneClip], temp_dir: str
    ) -> str:
        """Generate SRT subtitle file from clip narration texts."""
        srt_path = os.path.join(temp_dir, "subtitles.srt")
        current_time = 0.0

        with open(srt_path, "w") as f:
            for i, clip in enumerate(clips):
                if not clip.subtitle_text:
                    current_time += clip.duration
                    continue

                start = current_time
                end = current_time + clip.duration

                # Format timestamps: HH:MM:SS,mmm
                start_ts = self._format_srt_time(start)
                end_ts = self._format_srt_time(end)

                f.write(f"{i + 1}\n")
                f.write(f"{start_ts} --> {end_ts}\n")
                f.write(f"{clip.subtitle_text}\n\n")

                current_time = end

        return srt_path

    def _format_srt_time(self, seconds: float) -> str:
        """Format seconds to SRT timestamp format."""
        hours = int(seconds // 3600)
        minutes = int((seconds % 3600) // 60)
        secs = int(seconds % 60)
        millis = int((seconds - int(seconds)) * 1000)
        return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"

    def _run_ffmpeg_concat(
        self,
        concat_file: str,
        subtitle_file: Optional[str],
        output_path: str,
        temp_dir: str,
    ):
        """Run the final FFmpeg concat command."""
        cmd = [
            "ffmpeg",
            "-f", "concat",
            "-safe", "0",
            "-i", concat_file,
        ]

        # Add background music if provided
        music_input = []
        if self.config.background_music_path:
            music_input = [
                "-i", self.config.background_music_path,
                "-filter_complex",
                f"[1:a]volume={self.config.music_volume}[music];"
                f"[0:a][music]amix=inputs=2:duration=first:dropout_transition=2",
                "-map", "[a]",
            ]

        # Add subtitles
        subtitle_filter = []
        if subtitle_file:
            subtitle_filter = [
                "-vf", f"subtitles={subtitle_file}",
            ]

        # Add fade in/out
        if self.config.add_fade_in or self.config.add_fade_out:
            # We need to know total duration for fade out
            # This is handled in a separate pass if needed
            pass

        cmd.extend(music_input)
        cmd.extend(subtitle_filter)
        cmd.extend([
            "-c:v", self.config.video_codec,
            "-c:a", self.config.audio_codec,
            "-b:v", self.config.output_bitrate,
            "-pix_fmt", "yuv420p",
            "-movflags", "+faststart",
            "-y",
            output_path,
        ])

        subprocess.run(cmd, check=True, capture_output=True)

    def _generate_thumbnail(self, video_path: str, temp_dir: str) -> str:
        """Generate a thumbnail from the midpoint of the video."""
        thumbnail_path = os.path.join(
            os.path.dirname(video_path),
            f"{Path(video_path).stem}_thumb.jpg"
        )

        # Get video duration
        probe = subprocess.run(
            ["ffprobe", "-v", "error", "-show_entries",
             "format=duration", "-of",
             "default=noprint_wrappers=1:nokey=1", video_path],
            capture_output=True, text=True, check=True,
        )
        duration = float(probe.stdout.strip())
        midpoint = duration / 2

        cmd = [
            "ffmpeg",
            "-ss", str(midpoint),
            "-i", video_path,
            "-vframes", "1",
            "-q:v", "2",
            "-y",
            thumbnail_path,
        ]

        subprocess.run(cmd, check=True, capture_output=True)
        return thumbnail_path


# Usage example
if __name__ == "__main__":
    config = AssemblyConfig(
        output_path="nivant_analytics_demo.mp4",
        background_music_path="assets/background_music.mp3",
        transition_duration=0.3,
    )

    assembler = DemoAssembler(config)

    clips = [
        SceneClip(
            video_path="output/scene_001.mp4",
            narration_path="audio/scene_001.wav",
            transition="cut",
            duration=4.0,
            subtitle_text="Welcome to the Nivant Analytics dashboard. "
                          "Real-time metrics at a glance.",
        ),
        SceneClip(
            video_path="output/scene_002.mp4",
            narration_path="audio/scene_002.wav",
            transition="fade",
            duration=6.0,
            subtitle_text="Drilling into Analytics view with interactive charts.",
        ),
        SceneClip(
            video_path="output/scene_003.mp4",
            narration_path="audio/scene_003.wav",
            transition="cut",
            duration=5.0,
            subtitle_text="Filtering by region to see APAC performance data.",
        ),
    ]

    final_path = assembler.assemble(clips)
    print(f"Demo video assembled: {final_path}")

How to Use Effectively

Getting Started (10 minutes)

  1. Set up a Google Cloud account. Go to console.cloud.google.com, create a project, and enable the Vertex AI API. Veo 3 is available in the us-central1 region.
  2. Install the SDK: pip install google-cloud-aiplatform
  3. Authenticate: Run gcloud auth application-default login to set up credentials.
  4. Try a simple prompt:
from google.cloud import aiplatform

# Initialize the client
aiplatform.init(project="your-project-id", location="us-central1")

# Your first prompt — keep it simple
prompt = "Technical software demo showing a dashboard with real-time metrics. Clean UI, professional lighting, static camera."

The 5-Part Prompt Formula

After extensive testing, Priya found this prompt structure works best for technical demos:

[Subject] What the scene shows — be specific about UI elements and data
[Visuals] Key visual elements that must be accurate
[Motion] Camera movement (static for UI, pan for workflows)
[Style] Technical constraints — no artistic filters, accurate text
[Technical] Resolution, FPS, consistency requirements

Production-Grade Prompt Template

Here’s what this code does: It gives you reusable templates for common demo scenes. Instead of writing every prompt from scratch, you call a function like dashboard_overview() and it builds the right prompt for you.

"""
prompt_templates.py — Reusable Veo 3 prompt templates for technical demos.
"""

from typing import Optional, List
from dataclasses import dataclass


@dataclass
class DemoPrompt:
    """A structured Veo 3 prompt for technical demo generation."""
    subject: str
    visual_elements: List[str]
    motion: str = "static"
    style: str = "clean UI, accurate text rendering, professional lighting"
    technical: str = "1080p, 30fps, smooth motion, consistent UI elements"
    additional_instructions: Optional[str] = None

    def build(self) -> str:
        """Build the full Veo 3 prompt string."""
        # Only use the first 5 elements — too many details confuse the model
        elements = "; ".join(self.visual_elements[:5])
        prompt = (
            f"[Subject] {self.subject} "
            f"[Visuals] {elements}. "
            f"[Motion] {self.motion}. "
            f"[Style] {self.style}. "
            f"[Technical] {self.technical}."
        )
        if self.additional_instructions:
            prompt += f" {self.additional_instructions}"
        return prompt


# Pre-built templates for common demo scenarios

def dashboard_overview(
    metrics: List[str],
    layout: str = "grid with top navigation",
) -> DemoPrompt:
    """Template for dashboard overview scenes."""
    return DemoPrompt(
        subject=f"Software dashboard loading with real-time metrics: "
                f"{', '.join(metrics[:3])}",
        visual_elements=[layout] + metrics[:4],
        motion="static camera, tripod-mounted",
        style="clean UI, accurate text rendering, professional lighting, "
              "no motion blur",
        technical="1080p, 30fps, smooth motion, consistent UI elements "
                  "across frames, no hallucinations in text or UI components",
    )


def ui_interaction(
    action: str,
    element: str,
    result: str,
) -> DemoPrompt:
    """Template for UI interaction scenes (clicks, hovers, drags)."""
    return DemoPrompt(
        subject=f"User interaction: {action} on {element}",
        visual_elements=[f"{element} being {action}", result],
        motion="static camera following cursor movement",
        style="clean UI, accurate text rendering, smooth cursor animation, "
              "no motion blur",
        technical="1080p, 30fps, smooth motion, cursor visible and accurate",
        additional_instructions=f"Show the {element} clearly before and after the interaction",
    )


def data_visualization(
    chart_type: str,
    data_points: List[str],
    animation: str = "smooth transition",
) -> DemoPrompt:
    """Template for data visualization scenes."""
    return DemoPrompt(
        subject=f"{chart_type} chart updating with new data",
        visual_elements=[f"{chart_type} chart"] + data_points[:3],
        motion="static camera, no movement",
        style="clean data visualization, accurate labels, proper axis scaling, "
              "professional color scheme",
        technical="1080p, 30fps, smooth animation, no flickering, "
                  "consistent data labels",
    )


def api_response(
    request: str,
    response: str,
    format: str = "JSON",
) -> DemoPrompt:
    """Template for API response visualization scenes."""
    return DemoPrompt(
        subject=f"API {request} returning {response}",
        visual_elements=[f"Code editor showing {format} response",
                         f"Response: {response[:50]}"],
        motion="static camera",
        style="code editor view, syntax highlighting, monospace font, "
              "accurate text rendering",
        technical="1080p, 30fps, no motion blur, text must be readable",
    )


# Usage
if __name__ == "__main__":
    prompt = dashboard_overview(
        metrics=["1,247 active users", "p95 latency: 42ms", "error rate: 0.3%"],
        layout="grid with top navigation showing Overview, Analytics, Settings",
    )
    print(prompt.build())

Parameter Reference

Parameter Values Notes
duration_seconds 1-8 Veo 3 hard limit; extend with extend API
aspect_ratio 16:9, 9:16 16:9 for demos, 9:16 for mobile/shorts
fps 24, 30 30 for smooth UI motion
resolution 720p, 1080p 1080p for production
sampleCount 1-4 More = better selection, higher cost
reference_image GCS URI (max 20MB) Critical for UI consistency
seed Integer For reproducible results

Hidden Pitfall: Text Rendering

Veo 3, like all current video models, struggles with text rendering. Here’s what Priya learned:

  1. Keep text minimal — no more than 3-5 words on screen at a time
  2. Use reference images — provide a screenshot of the actual UI as reference
  3. Overlay text in post — generate the video without text, then add it with FFmpeg
  4. Avoid small fonts — anything under 24px will hallucinate
  5. Test with seed values — some seeds render text better than others

Use Cases

1. Product Demo Videos

When you’d use this: You need a 30-second product walkthrough from a script. The pipeline takes a markdown script and produces a complete video with transitions and narration.

Why Veo 3 fits: The combination of low cost ($48 vs $3,200) and fast turnaround (4 hours vs 14 days) means you can create demos for every feature, not just the big ones.

Before: $3,200, 14 days, 1 video After: $48, 4 hours, unlimited variations

2. UI State Transitions

When you’d use this: You want to show how the UI changes between states — loading, empty, error, success.

Why Veo 3 fits: Veo 3 handles these transitions smoothly when you give it reference images for each state. The motion coherence is excellent, so buttons and menus don’t flicker or morph.

3. Data Pipeline Visualization

When you’d use this: You need animated diagrams of data flowing through your architecture.

Why Veo 3 fits: The combination of motion coherence and reference image support makes this surprisingly effective. You can show data moving between services without it looking janky.

4. API Response Animation

When you’d use this: You want to show a code editor with an API call being made and the response appearing.

Why Veo 3 fits: This is where text rendering limitations matter most — keep the code snippets short (3-5 lines max) and overlay longer text in post-production.

5. Regression Test Video Generation

When you’d use this: You want to generate “expected behavior” videos from test descriptions, then compare them against actual recordings to catch visual bugs.

Why Veo 3 fits: The low cost makes it practical to generate hundreds of test videos. Compare generated videos against real recordings to detect visual regressions automatically.

Cheat Sheet

Quick Reference

# Model IDs
VEO_3_MODEL = "veo-3"  # Vertex AI model name
VEO_3_ENDPOINT = "us-central1-aiplatform.googleapis.com"

# Veo 3.1 Pricing (per 8-second clip)
VEO_31_STANDARD = 3.20   # $3.20 per 8s — best quality
VEO_31_FAST = 1.20       # $1.20 per 8s — faster turnaround
VEO_31_LITE = 0.15       # $0.15 per 8s — budget option

# Rate Limits (default quota)
VEO_3_RPM = 10  # Requests per minute
VEO_3_RPD = 1000  # Requests per day

# Common Errors
ERROR_QUOTA_EXCEEDED = "Quota exceeded for 'predict' requests"
ERROR_INVALID_PROMPT = "Prompt contains prohibited content"
ERROR_DURATION_EXCEEDED = "duration_seconds must be <= 8"

# Debugging Tips
# 1. Always use reference images for UI consistency
# 2. Keep prompts under 500 characters
# 3. Use seed values for reproducible results
# 4. Generate at 720p first for faster iteration
# 5. Check for NSFW false positives on UI screenshots
Aspect Detail
Best Model veo-3 on Vertex AI
Veo 3.1 Pricing Standard ($3.20/8s), Fast ($1.20/8s), Lite ($0.15/8s)
Max Clip Length 8 seconds (extendable)
Resolution 1080p (full HD)
API Latency ~45 seconds per clip
Rate Limit 10 requests per minute (default)
Reference Images Required for UI consistency (max 20MB)
Text Rendering Poor — overlay text in post-production
Google Cloud Native integration with Vertex AI
Region us-central1
Aspect Ratios 16:9, 9:16
Audio Native spatial audio (48kHz stereo)
Free Tier Limited free tier (varies) — great for prototyping and learning

Common Bug: SDK Routing

One bug Priya hit early on: when you set vertexai=True in the SDK, it routes to the v1beta1 endpoint by default. You must configure http_options to target v1 instead. Without this fix, your requests silently fail or return unexpected results.

# WRONG — routes to v1beta1 by default
client = genai.Client(vertexai=True)

# RIGHT — explicitly target v1
client = genai.Client(
    vertexai=True,
    http_options={"api_version": "v1"},
)

Async Operation Tracking

Veo 3 uses Google’s PredictLongRunning endpoint for async generation. You submit a job, get back an operation ID, and poll until it completes. Use exponential backoff for polling:

  • Base delay: 10 seconds
  • Multiplier: 1.2
  • Max delay: 45 seconds

This means you check after 10s, then 12s, then 14.4s, and so on, up to a maximum of 45 seconds between checks.

Video Extension

You can extend videos beyond 8 seconds by using the first or last frame as a reference for the next clip. This creates smooth continuations rather than jarring cuts.

Vibe Coding Projects

Project 1: Automated Demo Pipeline CLI

Build a CLI tool that takes a markdown script and outputs a complete demo video:

veo-demo create script.md --output demo.mp4 --music bg.mp3

The CLI should handle scene decomposition, generation, and assembly in one command. Add a --watch flag that regenerates the demo when the script changes.

Project 2: UI State Transition Library

Create a library that generates UI state transition videos from component props:

from veo_demo import StateTransition

transition = StateTransition(
    component="DataTable",
    from_state={"rows": [], "loading": True},
    to_state={"rows": data, "loading": False},
)
transition.render("loading_to_data.mp4")

Project 3: Regression Test Video Comparator

Build a CI plugin that generates “expected” videos from test descriptions and compares them against actual recordings:

# .github/workflows/demo-regression.yml
- name: Check demo visual regression
  uses: nivant/veo-regression@v1
  with:
    test-spec: tests/demo-scenarios.yaml
    threshold: 0.95  # 95% similarity required

Problems Solved Efficiently

Problem Type Why Veo 3 Fits When to Look Elsewhere
High demo production cost $0.08 per 8s clip vs $3,200 traditional You need photorealistic cinematic video (use Sora or Runway)
Slow turnaround 45s per clip, 4 hours for full demo You need real-time generation (doesn’t exist yet)
Stale demos Regenerate on every release for $48 Your UI changes every day (cost adds up)
Multi-language demos Pipeline generates narration in any language via Google Cloud TTS You need lip-synced avatars (use a dedicated tool)
UI consistency across scenes Reference images keep UI looking the same Your UI has lots of small text (overlay in post)
Scale to 100+ videos $2,256/quarter vs $150,400 traditional You need 4K resolution (Veo 3 maxes at 1080p)

The Results

Priya’s team ran the pipeline for one quarter. Here’s what happened:

Metric Before (Traditional) After (Veo 3 Pipeline) Improvement
Cost per video $3,200 $48 98.5% reduction
Turnaround time 14 days 4 hours 98.8% reduction
Videos per quarter 47 120 155% increase
Stale demos 23 0 100% elimination
Languages supported 1 5 400% increase
Regeneration cost $3,200 $48 Same-day updates

What this means for you: If you produce any kind of demo or tutorial video, Veo 3 changes the economics completely. A $3,200, 14-day project becomes a $48, 4-hour task. You can regenerate every demo on every release. Demo debt goes from a growing problem to zero.

The demo pipeline paid for itself in the first quarter. More importantly, it changed how Priya’s team thinks about demo content — from a quarterly production cycle to a continuous delivery model. Every release gets fresh demos. Every feature gets a walkthrough. Every language market gets localized versions.

Veo 3 isn’t just a video generation tool. It’s a force multiplier for developer relations, product marketing, and documentation teams. The combination of low cost, fast generation, and Google Cloud integration makes it the clear choice for technical demo production at scale.

Trade-offs and Lessons

What You Might Sacrifice

1. Text rendering accuracy — Veo 3 still hallucinates text. We overlay all text in post-production using FFmpeg subtitles.

  • Fix: Generate videos without text, add it in assembly.

2. Long-form coherence — Veo 3 generates 8-second clips. Longer scenes require stitching, which can introduce visual discontinuities.

  • Fix: Use reference images for each clip to maintain visual consistency.

3. Audio control — Veo 3 doesn’t generate audio. We use Google Cloud Text-to-Speech for narration and FFmpeg for background music mixing.

  • Fix: Build audio generation into the pipeline (see Stage 3).

Common Pitfalls (and How to Avoid Them)

1. NSFW false positives — Veo 3’s safety filters flagged UI screenshots as “prohibited content” because they contained buttons labeled “Submit” (apparently a trigger word).

  • Fix: Pre-process prompts to replace trigger words and add “technical software demo” context.

2. Rate limit surprises — The default Veo 3 quota is 10 RPM. Our first batch of 47 scenes took 5 minutes of wall time, but we hit the daily limit on the second batch.

  • Fix: Request a quota increase to 100 RPM and implement the rate limiter shown above.

3. Reference image format — Veo 3 requires reference images in GCS, not local paths. Our first pipeline failed silently because we passed local paths.

  • Fix: Add an explicit GCS upload step before generation.

4. SDK routing bug — Setting vertexai=True routes to v1beta1 by default. You must configure http_options to target v1.

  • Fix: Always set http_options={"api_version": "v1"} when using vertexai=True.

Beginner-Friendly Advice

  1. Start with 720p. Iterate on prompts at 720p, then regenerate at 1080p for final production. Saves 60% on generation time during development.

  2. Build a prompt library. Create templates for common scene types (dashboard, interaction, data viz, API response). Reuse and refine.

  3. Version your prompts. Store prompts alongside your code. When a demo breaks, you can trace it to a prompt change.

  4. Don’t expect perfection on the first try. Video generation is still an emerging technology. Plan for 2-3 iterations per scene.

  5. Keep it short. Veo 3 works best with 4-6 second clips. Longer clips increase the chance of visual artifacts.

Course-Style Deep Dive

How Veo 3 Works (Simplified)

Think of Veo 3 as a digital artist who starts with a blank canvas of static noise (like TV static) and slowly removes the noise until a clear video emerges. It’s called a diffusion model — it “diffuses” (spreads) noise into an image, then reverses the process to create something new.

The architecture has four main parts:

  1. Spatiotemporal attention — This is how the model keeps things consistent. “Spatial” means within a single frame (a button stays a button). “Temporal” means across frames (the button doesn’t change into a checkbox in the next frame). Veo 3 processes both at the same time, which is why its motion is so smooth.

  2. Joint image-video training — Veo 3 was trained on both still images and videos. This means it can use a screenshot as a reference. For technical demos, this is critical — you give it a screenshot of your actual UI, and it generates video that looks like that UI.

  3. Latent diffusion — Instead of working with raw pixels (which is slow and memory-intensive), Veo 3 works in a “compressed” version of the video, like a zip file. It generates the video in this compressed space, then decompresses it to full resolution. This makes it much faster.

  4. Classifier-free guidance (CFG) — This controls how closely the model follows your prompt. High CFG = more faithful to your description but less creative. Low CFG = more creative but might ignore your instructions. For technical demos, use high CFG.

The generation pipeline:

Random noise (64x64x4 latent)
    |
    v
[Video Diffusion Transformer]  ←  Your prompt guides the denoising
    |                              ←  Reference image keeps UI consistent
    |                              ←  CFG scale controls prompt adherence
    v
Denoised latent (64x64x4)
    |
    v
[VAE Decoder]  — Decompresses the latent into full video
    |
    v
Final video (1920x1080, 30fps, 8s)

Advanced Patterns

Pattern 1: Reference Image Chains

For multi-scene demos, use the output of one scene as the reference image for the next:

def generate_scene_chain(prompts: List[str], output_dir: str):
    """Generate a chain of scenes where each scene references the previous."""
    previous_video = None

    for i, prompt in enumerate(prompts):
        # Extract a frame from the previous video as reference
        if previous_video:
            ref_image = extract_frame(previous_video, time=0.5)
            upload_to_gcs(ref_image, f"ref_scene_{i}.jpg")

        # Generate with reference
        video = generate_veo3(
            prompt=prompt,
            reference_image=f"gs://bucket/ref_scene_{i}.jpg" if previous_video else None,
        )
        previous_video = video

Pattern 2: Timestamp Prompting

Include specific timestamps in your prompts to guide temporal behavior:

[0:00-0:02] Dashboard loads with skeleton UI
[0:02-0:04] Data appears with animation
[0:04-0:06] Charts render with real-time data
[0:06-0:08] Final state with all metrics visible

Pattern 3: Extend-and-Stitch

Veo 3’s 8-second limit can be overcome by extending clips and stitching:

def extend_scene(base_prompt: str, target_duration: int = 30):
    """Extend a scene beyond 8 seconds using Veo 3's extend API."""
    clips = []
    current_prompt = base_prompt

    for _ in range(target_duration // 8):
        clip = veo3_generate(current_prompt)
        clips.append(clip)

        # Use last frame as reference for next extension
        last_frame = extract_frame(clip, time=7.5)
        current_prompt = f"{base_prompt} [continuing from previous frame]"

    return stitch_clips(clips)

Production Considerations

Monitoring

# Prometheus metrics for the demo pipeline
DEMO_GENERATION_COST = Counter(
    "demo_generation_cost_dollars",
    "Total cost of Veo 3 generations",
    ["project", "team"],
)
DEMO_GENERATION_LATENCY = Histogram(
    "demo_generation_latency_seconds",
    "Latency of Veo 3 generations",
    buckets=[10, 20, 30, 45, 60, 90, 120, 180],
)
DEMO_GENERATION_ERRORS = Counter(
    "demo_generation_errors_total",
    "Total Veo 3 generation errors",
    ["error_type"],
)

Retry with Exponential Backoff

The veo3_generator.py code above implements retry with exponential backoff. Key parameters:

  • Max retries: 3
  • Base delay: 2 seconds
  • Backoff factor: 2x (2s, 4s, 8s)
  • Jitter: ±25% to avoid thundering herd

Cost Tracking

# Monthly cost projection
COST_PER_SECOND = 0.01
AVG_SCENE_DURATION = 5.0
SCENES_PER_DEMO = 10
DEMOS_PER_MONTH = 40

monthly_cost = (
    COST_PER_SECOND
    * AVG_SCENE_DURATION
    * SCENES_PER_DEMO
    * DEMOS_PER_MONTH
)
print(f"Monthly cost: ${monthly_cost:.2f}")
# Monthly cost: $20.00

CI/CD Integration

# .github/workflows/demo-generation.yml
name: Generate Demo Videos
on:
  release:
    types: [published]
  workflow_dispatch:
    inputs:
      script_path:
        description: "Path to demo script"
        required: true

jobs:
  generate-demo:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Authenticate to GCP
        uses: google-github-actions/auth@v2
        with:
          credentials_json: ${{ secrets.GCP_SA_KEY }}

      - name: Generate demo video
        run: |
          python pipeline/run.py \
            --script ${{ inputs.script_path }} \
            --output dist/demo.mp4 \
            --music assets/background.mp3

      - name: Upload to CDN
        uses: google-github-actions/upload-cloud-storage@v2
        with:
          path: dist/demo.mp4
          destination: demos.nivantlabs.com/${{ github.ref_name }}/

      - name: Update demo index
        run: |
          python pipeline/update_index.py \
            --version ${{ github.ref_name }} \
            --video-url https://demos.nivantlabs.com/${{ github.ref_name }}/demo.mp4

Full Google Cloud Stack Integration

┌─────────────────────────────────────────────────────────┐
│                    Trigger (GitHub Release)              │
└──────────────────────┬──────────────────────────────────┘

┌──────────────────────▼──────────────────────────────────┐
│              Cloud Run Job (pipeline runner)             │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  │
│  │ Scene        │  │ Veo 3        │  │ Assembly     │  │
│  │ Decomposer   │──▶ Generator    │──▶ & Audio Mix  │  │
│  │ (Gemini 2.5) │  │ (Async)      │  │ (FFmpeg)     │  │
│  └──────────────┘  └──────┬───────┘  └──────┬───────┘  │
│                            │                  │          │
└────────────────────────────┼──────────────────┼──────────┘
                             │                  │
              ┌──────────────▼──────┐  ┌────────▼────────┐
              │ Cloud Storage       │  │ Cloud Storage   │
              │ (generated clips)   │  │ (final video)   │
              └─────────────────────┘  └────────┬────────┘

                              ┌──────────────────▼──────────┐
                              │  Cloud CDN (demos.nivant...) │
                              └─────────────────────────────┘

Next in the AI Tools Mastery series: Claude Code for Agentic Development

NL

Written by Nivant Labs Team

Engineer at Nivant Labs

Share this post