Event-driven video processing with Inngest and Ittybit

View Markdown

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