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.
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-keyto 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