Event-driven video processing with Inngest and Ittybit
Inngest step functions give you durable, retryable workflows without managing queues. Pair them with Ittybit and you get an event-driven pipeline that transcodes video, extracts thumbnails, and fans out to multiple formats — all triggered by a single upload event.
Send the event
When a video is uploaded, fire a video/uploaded event from your API route or webhook handler. Inngest picks it up and runs the function.
import { inngest } from './client';
// In your upload handler
await inngest.send({
name: 'video/uploaded',
data: {
videoId: 'vid_abc123',
sourceUrl: 'https://your-bucket.s3.amazonaws.com/uploads/raw.mov',
},
});
Define the function
Each step.run is independently retried on failure. If the transcode step succeeds but the thumbnail step fails, Inngest retries only the thumbnail — it won’t re-transcode.
import { inngest } from './client';
const ITTYBIT_API = 'https://api.ittybit.com';
async function createTask(body: Record<string, unknown>) {
const res = await fetch(`${ITTYBIT_API}/tasks`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ITTYBIT_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Ittybit API error: ${res.status}`);
return res.json();
}
async function pollTask(taskId: string) {
while (true) {
const res = await fetch(`${ITTYBIT_API}/tasks/${taskId}`, {
headers: { Authorization: `Bearer ${process.env.ITTYBIT_API_KEY}` },
});
const task = await res.json();
if (task.status === 'completed') return task;
if (task.status === 'failed') throw new Error(`Task ${taskId} failed`);
await new Promise((r) => setTimeout(r, 5000));
}
}
export const processVideo = inngest.createFunction(
{ id: 'process-video', retries: 3 },
{ event: 'video/uploaded' },
async ({ event, step }) => {
const { videoId, sourceUrl } = event.data;
// Step 1: Transcode to MP4
const transcode = await step.run('transcode', async () => {
const task = await createTask({
input: sourceUrl,
kind: 'video',
options: { width: 1920, format: 'mp4', quality: 'high' },
metadata: { videoId },
});
return pollTask(task.id);
});
// Step 2: Extract thumbnail from the transcoded video
const thumbnail = await step.run('thumbnail', async () => {
const task = await createTask({
input: transcode.output.url,
kind: 'image',
options: { start: 2, width: 640, format: 'webp' },
metadata: { videoId },
});
return pollTask(task.id);
});
// Step 3: Update DB and notify
await step.run('notify', async () => {
await db.video.update({
where: { id: videoId },
data: {
status: 'ready',
mp4Url: transcode.output.url,
thumbnailUrl: thumbnail.output.url,
},
});
await sendNotification(videoId, 'Video processing complete');
});
return { videoId, status: 'ready' };
},
);
Fan out to multiple formats
For multiple output formats, use step.run inside a loop. Each format gets its own retryable step, and Inngest runs them concurrently.
export const processMultipleFormats = inngest.createFunction(
{ id: 'process-multi-format', retries: 3 },
{ event: 'video/uploaded' },
async ({ event, step }) => {
const { videoId, sourceUrl } = event.data;
const formats = [
{ width: 1920, format: 'mp4', quality: 'high', label: '1080p' },
{ width: 1280, format: 'mp4', quality: 'medium', label: '720p' },
{ width: 640, format: 'mp4', quality: 'medium', label: '360p' },
];
const results = await Promise.all(
formats.map((fmt) =>
step.run(`transcode-${fmt.label}`, async () => {
const task = await createTask({
input: sourceUrl,
kind: 'video',
options: { width: fmt.width, format: fmt.format, quality: fmt.quality },
metadata: { videoId, label: fmt.label },
});
return pollTask(task.id);
}),
),
);
await step.run('save-results', async () => {
await db.video.update({
where: { id: videoId },
data: {
status: 'ready',
variants: results.map((r, i) => ({
label: formats[i].label,
url: r.output.url,
})),
},
});
});
return { videoId, variants: results.length };
},
);
Serve the functions
Register your functions with the Inngest serve handler. This works with Next.js, Express, Hono, or any supported framework.
// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/inngest/client";
import { processVideo, processMultipleFormats } from "@/inngest/functions";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [processVideo, processMultipleFormats],
});// server.ts
import express from 'express';
import { serve } from 'inngest/express';
import { inngest } from './inngest/client';
import { processVideo, processMultipleFormats } from './inngest/functions';
const app = express();
app.use(
'/api/inngest',
serve({ client: inngest, functions: [processVideo, processMultipleFormats] }),
);
app.listen(3000); See also
- Build a user upload pipeline — multi-task processing for uploads
- Process uploads in Next.js — Server Actions and webhook callbacks
- HLS streaming — adaptive bitrate output