Back to Blog
Guides

Building a Production Video Pipeline with Lumavo: From Script to Final Render

Learn how studios and development teams are building automated, production-grade AI video pipelines with Lumavo's API. Architecture, code samples, and performance optimization for high-volume workflows.

May 28, 202615 min read
APIProductionWorkflowEnterprisePipeline

The Production Challenge

Generating one AI video is easy. Generating hundreds — with consistent quality, reliable error handling, cost controls, and automated post-processing — is an engineering challenge. This guide shows you how to build a production-grade pipeline that scales from prototype to high-volume operation.

Pipeline Architecture Overview

A robust video pipeline has six stages. Each stage should be independently scalable and observable:

[Script/Concept] → [Prompt Generation] → [Batch Dispatch] → [Status Monitoring] → [Quality Review] → [Delivery]

↓ ↓ ↓ ↓ ↓ ↓

Content Mgmt Prompt Engine Lumavo API Webhook Handler Human/CV Check CDN/Storage

Stage 1: Script and Concept Management

Before generating video, you need structured input. For high-volume pipelines, scripts should live in a database with metadata:

CREATE TABLE video_tasks (

id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

project_id UUID NOT NULL REFERENCES projects(id),

concept TEXT NOT NULL,

style TEXT,

target_duration INT DEFAULT 5,

target_resolution VARCHAR DEFAULT '720p',

aspect_ratio VARCHAR DEFAULT '16:9',

model VARCHAR DEFAULT 'seedance-20',

priority INT DEFAULT 0,

status VARCHAR DEFAULT 'draft',

created_at TIMESTAMPTZ DEFAULT now()

);

Stage 2: Prompt Engineering Engine

Do not let raw user input hit the API directly. A prompt engineering layer enriches, validates, and optimizes prompts:

interface PromptTemplate {

style: string;

camera: string;

lighting: string;

negativePrompt: string;

}

const STYLE_PRESETS: Record<string, PromptTemplate> = {

cinematic: {

style: "cinematic style, anamorphic lens, shallow depth of field, film grain",

camera: "smooth dolly movement",

lighting: "dramatic side lighting with soft fill",

negativePrompt: "blurry, static, low contrast, flat lighting, text overlay, watermark"

},

commercial: {

style: "commercial product videography, clean and polished, high key lighting",

camera: "slow orbit around subject",

lighting: "bright studio lighting with soft shadows",

negativePrompt: "dark, moody, handheld shake, amateur, grainy"

},

documentary: {

style: "documentary filmmaking, naturalistic, handheld feel",

camera: "subtle handheld motion",

lighting: "available natural light, practical sources",

negativePrompt: "overly polished, artificial lighting, CGI look, studio"

}

};

function buildPrompt(

concept: string,

stylePreset: string,

duration: number

): { prompt: string; negativePrompt: string } {

const preset = STYLE_PRESETS[stylePreset] || STYLE_PRESETS.cinematic;

const prompt = [

concept,

preset.style,

preset.camera,

preset.lighting,

Duration: ${duration} seconds of smooth, coherent motion.

].join(". ");

return {

prompt: prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(),

negativePrompt: preset.negativePrompt

};

}

Build a Prompt Validator

Add a validation layer that catches common issues before spending credits:

function validatePrompt(prompt: string): { valid: boolean; issues: string[] } {

const issues: string[] = [];

if (prompt.length < 20) {

issues.push("Prompt is too short (< 20 chars). Add more detail.");

}

if (prompt.length > 10000) {

issues.push("Prompt exceeds 10,000 character limit.");

}

const motionKeywords = [

"walking", "running", "flying", "flowing", "drifting",

"pan", "dolly", "zoom", "tilt", "tracking",

"wind", "rain", "snow", "ripple", "wave", "sway",

"movement", "motion", "moving", "floating", "rising"

];

const hasMotion = motionKeywords.some((kw) =>

prompt.toLowerCase().includes(kw)

);

if (!hasMotion) {

issues.push("Prompt may lack motion description. AI video needs movement cues.");

}

if (!prompt.includes(".") && prompt.length > 200) {

issues.push("Long prompt without punctuation. Break into sentences.");

}

return { valid: issues.length === 0, issues };

}

Stage 3: Batch Dispatch with Concurrency Control

Dispatching hundreds of tasks requires careful concurrency management to respect rate limits and control costs:

import pLimit from "p-limit";

interface GenerationTask {

prompt: string;

model: string;

duration: number;

resolution: string;

aspectRatio: string;

cost: number;

}

class VideoPipeline {

private concurrency: number;

private maxCredits: number;

private creditsUsed: number = 0;

private results: Map<string, { taskId: string; status: string }> = new Map();

constructor(

private apiKey: string,

concurrency: number = 10,

maxCredits: number = 10000

) {

this.concurrency = concurrency;

this.maxCredits = maxCredits;

}

async dispatchBatch(tasks: GenerationTask[]): Promise<void> {

// Sort by priority (lowest cost first to maximize output if budget runs out)

const sorted = [...tasks].sort((a, b) => a.cost - b.cost);

const limit = pLimit(this.concurrency);

const dispatchPromises = sorted.map((task) =>

limit(async () => {

if (this.creditsUsed + task.cost > this.maxCredits) {

console.warn(Skipping task — would exceed credit budget (${this.creditsUsed}/${this.maxCredits}));

return;

}

try {

const response = await fetch("https://api.lumavo.com/v1/txt2video", {

method: "POST",

headers: {

"Authorization": Bearer ${this.apiKey},

"Content-Type": "application/json",

"x-idempotency-key": crypto.randomUUID(),

},

body: JSON.stringify({

model: task.model,

prompt: task.prompt,

duration: task.duration,

resolution: task.resolution,

aspectRatio: task.aspectRatio,

webhookUrl: "https://your-server.com/api/lumavo-webhook",

}),

});

if (!response.ok) {

const error = await response.text();

console.error(Dispatch failed (${response.status}): ${error});

return;

}

const data = await response.json();

this.creditsUsed += task.cost;

this.results.set(data.taskId, { taskId: data.taskId, status: "queued" });

console.log(Dispatched: ${data.taskId} | Credits used: ${this.creditsUsed}/${this.maxCredits});

} catch (err) {

console.error("Network error during dispatch:", err);

}

})

);

await Promise.allSettled(dispatchPromises);

console.log(Batch dispatch complete. ${this.results.size} tasks queued.);

}

}

Cost Estimation Before Dispatch

Always estimate total cost before firing off a batch:

const MODEL_COST: Record<string, number> = {

"seedance-20": 40,

"seedance-21": 40,

"kling-30": 60,

"happyhorse-10": 30,

};

function estimateBatchCost(tasks: GenerationTask[]): number {

return tasks.reduce((total, task) => {

const costPerSecond = MODEL_COST[task.model] || 40;

return total + costPerSecond * task.duration;

}, 0);

}

// Before dispatching:

const estimatedCost = estimateBatchCost(allTasks);

console.log(Estimated cost: ${estimatedCost} credits);

console.log(Available budget: ${availableCredits} credits);

if (estimatedCost > availableCredits) {

throw new Error(

Insufficient credits. Need ${estimatedCost}, have ${availableCredits}.

);

}

Stage 4: Status Monitoring with Webhooks

Polling is wasteful at scale. Lumavo's webhook system pushes status updates directly to your server:

import { Hono } from "hono";

import crypto from "crypto";

const app = new Hono();

app.post("/api/lumavo-webhook", async (c) => {

const rawBody = await c.req.text();

const signature = c.req.header("x-lumavo-signature");

// Verify signature

const expected = crypto

.createHmac("sha256", process.env.LUMAVO_WEBHOOK_SECRET!)

.update(rawBody, "utf-8")

.digest("hex");

if (!crypto.timingSafeEqual(Buffer.from(signature || ""), Buffer.from(expected))) {

return c.json({ error: "Invalid signature" }, 401);

}

const event = JSON.parse(rawBody);

switch (event.status) {

case "completed":

await handleCompletion(event);

break;

case "failed":

await handleFailure(event);

break;

case "processing":

updateProgress(event.taskId, event.progress);

break;

}

return c.json({ received: true });

});

async function handleCompletion(event: {

taskId: string;

output: { url: string; duration: number; resolution: string };

}): Promise<void> {

// 1. Update database

await db.query(

UPDATE video_tasks SET status = 'completed', output_url = $1, completed_at = now() WHERE task_id = $2,

[event.output.url, event.taskId]

);

// 2. Download output to your CDN (optional but recommended — Lumavo CDN URLs expire)

const response = await fetch(event.output.url);

const buffer = await response.arrayBuffer();

await uploadToCDN(outputs/${event.taskId}.mp4, buffer);

// 3. Notify the user (email, Slack, in-app notification)

await notifyUser(event.taskId, "Your video is ready!");

}

Stage 5: Automated Quality Review

For high-volume pipelines, implement automated quality checks before deliverables reach a human reviewer:

interface QualityCheck {

name: string;

check: (videoUrl: string, metadata: VideoMetadata) => Promise<QualityResult>;

}

interface QualityResult {

passed: boolean;

score: number;

details: string;

}

const qualityChecks: QualityCheck[] = [

{

name: "duration-match",

check: async (_, meta) => ({

passed: Math.abs(meta.duration - meta.requestedDuration) < 1,

score: 1,

details: Actual: ${meta.duration}s, Requested: ${meta.requestedDuration}s,

}),

},

{

name: "resolution-match",

check: async (_, meta) => ({

passed: meta.resolution === meta.requestedResolution,

score: 1,

details: Actual: ${meta.resolution}, Requested: ${meta.requestedResolution},

}),

},

{

name: "file-size-sanity",

check: async (url) => {

const response = await fetch(url, { method: "HEAD" });

const size = parseInt(response.headers.get("content-length") || "0");

// Flag suspiciously small files (likely errors) or extremely large ones

return {

passed: size > 500_000 && size < 500_000_000,

score: size > 1_000_000 ? 1 : 0.5,

details: File size: ${(size / 1_000_000).toFixed(1)} MB,

};

},

},

];

async function runQualityReview(taskId: string): Promise<QualityResult[]> {

const results: QualityResult[] = [];

const meta = await getVideoMetadata(taskId);

for (const check of qualityChecks) {

const result = await check.check(meta.outputUrl, meta);

results.push(result);

if (!result.passed) {

console.warn(Quality check "${check.name}" failed for ${taskId}: ${result.details});

}

}

return results;

}

Stage 6: Delivery and Post-Processing

The final stage delivers polished output to your storage and distribution channels:

async function deliverVideo(taskId: string): Promise<DeliveryResult> {

const task = await getTask(taskId);

const videoBuffer = await downloadVideo(task.outputUrl);

// Post-processing with FFmpeg (if needed):

// - Add branding watermark

// - Normalize audio levels

// - Convert format for target platform

// - Generate thumbnail sprite

const thumbnailUrl = await generateThumbnail(videoBuffer);

// Upload to CDN

const cdnUrl = await uploadToCDN(

videos/${task.projectId}/${taskId}.mp4,

videoBuffer

);

// Update database

await db.query(

UPDATE video_tasks SET cdn_url = $1, thumbnail_url = $2, status = 'delivered' WHERE task_id = $3,

[cdnUrl, thumbnailUrl, taskId]

);

return { taskId, cdnUrl, thumbnailUrl, deliveredAt: new Date().toISOString() };

}

Putting It All Together

Here is a complete orchestration function:

async function runPipeline(

projectId: string,

concepts: string[],

options: {

model?: string;

style?: string;

duration?: number;

resolution?: string;

concurrency?: number;

} = {}

): Promise<PipelineResult> {

const {

model = "seedance-20",

style = "cinematic",

duration = 5,

resolution = "720p",

concurrency = 10,

} = options;

console.log(Starting pipeline for project ${projectId});

console.log(${concepts.length} concepts | ${model} | ${duration}s | ${resolution});

// Stage 1 & 2: Build and validate prompts

const tasks: GenerationTask[] = [];

for (const concept of concepts) {

const { prompt, negativePrompt } = buildPrompt(concept, style, duration);

const validation = validatePrompt(prompt);

if (!validation.valid) {

console.warn(Skipping concept — validation failed: ${validation.issues.join("; ")});

continue;

}

tasks.push({

prompt,

model,

duration,

resolution,

aspectRatio: "16:9",

cost: MODEL_COST[model] * duration,

});

}

// Stage 3: Estimate and dispatch

const estimatedCost = estimateBatchCost(tasks);

const availableCredits = await getCreditBalance();

if (estimatedCost > availableCredits) {

throw new Error(Insufficient credits: need ${estimatedCost}, have ${availableCredits});

}

console.log(Dispatching ${tasks.length} tasks (est. ${estimatedCost} credits)...);

const pipeline = new VideoPipeline(process.env.LUMAVO_API_KEY!, concurrency, availableCredits);

await pipeline.dispatchBatch(tasks);

// Stages 4-6: Monitoring, review, and delivery are handled

// asynchronously via webhooks (see webhook handler above)

return {

projectId,

tasksDispatched: tasks.length,

estimatedCost,

queuedAt: new Date().toISOString(),

};

}

// Usage

const result = await runPipeline("project_123", [

"Drone shot of a tropical island at sunrise",

"Close-up of a chef preparing sushi with precision",

"Time-lapse of a city skyline from day to night",

"Slow-motion action sequence of a skateboarder",

], {

model: "seedance-20",

style: "cinematic",

duration: 5,

concurrency: 8,

});

console.log(Pipeline launched: ${result.tasksDispatched} tasks queued);

Production Checklist

Before deploying a pipeline to production, verify:

  • [ ] Idempotency: Use x-idempotency-key to prevent duplicate charges on retry
  • [ ] Credit monitoring: Set up alerts when balance drops below a threshold
  • [ ] Webhook retries: Your endpoint must be idempotent — Lumavo may deliver webhooks multiple times
  • [ ] Error queues: Failed generations should go to a dead-letter queue for manual review
  • [ ] Cost tracking: Log credit consumption per project/client for billing
  • [ ] Output caching: Store generated videos indefinitely (Lumavo CDN URLs expire after 30 days)
  • [ ] Rate limit handling: Implement exponential backoff with jitter for 429 responses
  • [ ] Dry-run mode: Provide a flag to validate prompts and estimate costs without charging credits

Scaling Considerations

Database

As your pipeline scales, the video_tasks table becomes write-heavy. Consider:

  • Partitioning by created_at (monthly)
  • Moving completed tasks to an archive table
  • Using a dedicated read replica for dashboards

Compute

The prompt engineering and post-processing stages are CPU-bound (video transcoding). Run them on worker processes, not your web server:

# docker-compose.yml

services:

api:

build: .

environment:

- DATABASE_URL=...

- LUMAVO_API_KEY=...

pipeline-worker:

build: .

command: node workers/pipeline-worker.js

environment:

- DATABASE_URL=...

- LUMAVO_API_KEY=...

deploy:

replicas: 3

Next Steps

  • [Lumavo API Documentation](/docs/api) — Complete endpoint reference
  • [Model Comparison](/blog/model-comparison-seedance-vs-kling) — Choose the right model
  • [Prompt Engineering Guide](/blog/mastering-prompts-ai-video) — Write better prompts
  • [Contact Enterprise Sales](/contact) — Custom SLAs and dedicated support