DEV Community

Shayan Nadeem
Shayan Nadeem

Posted on

How I Built a Bulk Video Processing Pipeline with Next.js, BullMQ, and FFmpeg

I've been building BatchEdits — a tool that lets content creators upload 50 videos at once and get them back edited simultaneously with silence removed, captions added, and cropped for every platform.

The hardest engineering problem was making 50 videos process truly in parallel without the server falling over.

Here's exactly how I built it.


The Problem With Sequential Processing

The naive approach is obvious:

for (const video of videos) {
  await processVideo(video);
}
Enter fullscreen mode Exit fullscreen mode

For 50 videos this means:

  • Video 1 finishes → Video 2 starts
  • Total time = sum of all processing times
  • One failure stops everything
  • User waits the entire duration

For a 5-minute video that takes
2 minutes to process — 50 videos
would take 100 minutes sequentially.

We needed parallel processing.


The Architecture

Browser → Next.js API → BullMQ Queue 
                              ↓
                     Worker Pool (N workers)
                     ├── Worker 1 → FFmpeg
                     ├── Worker 2 → FFmpeg  
                     ├── Worker 3 → Whisper
                     └── Worker 4 → FFmpeg
                              ↓
                     Cloudflare R2 (output)
                              ↓
                     Webhook → User notified
Enter fullscreen mode Exit fullscreen mode

The key insight: BullMQ handles the queue and worker orchestration. FFmpeg handles the actual video processing. Whisper handles transcription. They run concurrently.


Direct Upload to R2 (Not Through Server)

The first mistake most people make is routing video uploads through their Next.js server:

Browser → Next.js → R2
Enter fullscreen mode Exit fullscreen mode

This is slow, wastes server bandwidth, and hits Next.js body size limits.

Instead use presigned URLs for direct browser → R2 upload:

// Generate presigned URL server-side
const url = await getSignedUrl(
  s3Client,
  new PutObjectCommand({
    Bucket: process.env.R2_BUCKET_NAME,
    Key: `uploads/${uuid}-${filename}`,
    ContentType: file.type,
  }),
  { expiresIn: 3600 }
);

// Return URL to browser
return { url, key };
Enter fullscreen mode Exit fullscreen mode
// Browser uploads directly to R2
// Using XHR for progress tracking
function uploadWithProgress(
  url: string,
  file: File,
  onProgress: (pct: number) => void
): Promise<void> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("PUT", url);
    xhr.setRequestHeader("Content-Type", file.type);

    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        onProgress(Math.round(e.loaded / e.total * 100));
      }
    };

    xhr.onload = () => 
      xhr.status === 200 ? resolve() : reject();
    xhr.send(file);
  });
}
Enter fullscreen mode Exit fullscreen mode

Upload 50 files in parallel:

// p-limit prevents overwhelming 
// the connection on slow networks
import pLimit from "p-limit";
const limit = pLimit(5);

await Promise.all(
  files.map((file, i) =>
    limit(() => uploadWithProgress(
      presignedUrls[i].url,
      file,
      (pct) => updateProgress(i, pct)
    ))
  )
);
Enter fullscreen mode Exit fullscreen mode

BullMQ For Parallel Processing

After upload, queue all videos:

// Add all jobs to queue at once
await Promise.all(
  uploadedFiles.map((file) =>
    videoQueue.add("process-video", {
      userId,
      videoId: file.id,
      key: file.r2Key,
      settings: presetSettings,
    })
  )
);
Enter fullscreen mode Exit fullscreen mode

The worker processes each job independently:

// workers/videoProcessor.ts
const worker = new Worker(
  "video-processing",
  async (job) => {
    const { videoId, key, settings } = job.data;

    const startTime = Date.now();

    // Download from R2
    const videoBuffer = await downloadFromR2(key);

    // Transcribe with Whisper
    const transcription = await transcribeAudio(
      videoBuffer
    );

    // Remove silence using FFmpeg
    const silenceRemoved = await removeSilence(
      videoBuffer,
      transcription.sentences,
      settings.silenceLevel
    );

    // Burn captions
    const withCaptions = await burnCaptions(
      silenceRemoved,
      transcription.sentences,
      settings.captionStyle
    );

    // Apply zoom
    const withZoom = await applyZoom(
      withCaptions,
      transcription.sentences,
      settings.zoomLevel
    );

    // Crop for platform
    const output = await cropVideo(
      withZoom,
      settings.cropFormat
    );

    // Upload output to R2
    const outputKey = await uploadToR2(output);

    // Store processing time for analytics
    const processingTime = 
      (Date.now() - startTime) / 1000;

    await db.update(processedVideos)
      .set({
        status: "completed",
        outputKey,
        totalProcessingTimeSeconds: 
          Math.round(processingTime),
      })
      .where(eq(processedVideos.id, videoId));
  },
  {
    connection: redisConnection,
    concurrency: 10, // 10 videos simultaneously
  }
);
Enter fullscreen mode Exit fullscreen mode

concurrency: 10 means 10 videos process at the same time per worker instance. You can run multiple worker instances for more parallelism.


Silence Detection With FFmpeg

The core silence removal uses FFmpeg's silencedetect filter:

async function detectSilence(
  inputPath: string,
  threshold: number = -30, // dB
  minDuration: number = 1.5 // seconds
): Promise<{ start: number; end: number }[]> {

  return new Promise((resolve, reject) => {
    const silences: { start: number; end: number }[] = [];
    let currentStart: number | null = null;

    const ffmpeg = spawn("ffmpeg", [
      "-i", inputPath,
      "-af", `silencedetect=noise=${threshold}dB:d=${minDuration}`,
      "-f", "null",
      "-",
    ]);

    ffmpeg.stderr.on("data", (data: Buffer) => {
      const output = data.toString();

      const startMatch = output.match(
        /silence_start: ([\d.]+)/
      );
      const endMatch = output.match(
        /silence_end: ([\d.]+)/
      );

      if (startMatch) {
        currentStart = parseFloat(startMatch[1]);
      }
      if (endMatch && currentStart !== null) {
        silences.push({
          start: currentStart,
          end: parseFloat(endMatch[1]),
        });
        currentStart = null;
      }
    });

    ffmpeg.on("close", () => resolve(silences));
    ffmpeg.on("error", reject);
  });
}
Enter fullscreen mode Exit fullscreen mode

Then cut the detected silences:

async function removeSilences(
  inputPath: string,
  silences: { start: number; end: number }[],
  outputPath: string
): Promise<void> {

  // Build FFmpeg filter to keep 
  // everything except silence segments
  const keepSegments = getKeepSegments(
    silences, 
    videoDuration
  );

  const filterParts = keepSegments.map(
    (seg, i) => 
      `[0:v]trim=${seg.start}:${seg.end},` +
      `setpts=PTS-STARTPTS[v${i}];` +
      `[0:a]atrim=${seg.start}:${seg.end},` +
      `asetpts=PTS-STARTPTS[a${i}]`
  );

  const concatInputs = keepSegments
    .map((_, i) => `[v${i}][a${i}]`)
    .join("");

  const filter = [
    ...filterParts,
    `${concatInputs}concat=n=${keepSegments.length}` +
    `:v=1:a=1[outv][outa]`
  ].join(";");

  await runFFmpeg([
    "-i", inputPath,
    "-filter_complex", filter,
    "-map", "[outv]",
    "-map", "[outa]",
    outputPath,
  ]);
}
Enter fullscreen mode Exit fullscreen mode

Caption Burning With Word-Level Timestamps

Whisper returns word-level timestamps which enable word-by-word caption highlighting:

const transcription = await openai.audio
  .transcriptions.create({
    file: audioFile,
    model: "whisper-1",
    response_format: "verbose_json",
    timestamp_granularities: ["word"],
  });

// transcription.words contains:
// [{ word: "Hello", start: 0.0, end: 0.3 }]
Enter fullscreen mode Exit fullscreen mode

Burn captions using FFmpeg drawtext:

function buildCaptionFilter(
  words: { word: string; start: number; end: number }[],
  style: CaptionStyle
): string {
  return words.map((word) => {
    const escaped = word.word
      .replace(/'/g, "\\'")
      .replace(/:/g, "\\:");

    return (
      `drawtext=text='${escaped}':` +
      `fontsize=${style.fontSize}:` +
      `fontcolor=${style.color}:` +
      `x=(w-text_w)/2:` +
      `y=(h-text_h)/2:` +
      `enable='between(t,${word.start},${word.end})'`
    );
  }).join(",");
}
Enter fullscreen mode Exit fullscreen mode

What I Learned

1. BullMQ concurrency is the key lever
Start with concurrency: 5 and increase based on your server's RAM. FFmpeg is CPU and memory intensive — too many concurrent jobs will crash your server.

2. Presigned URLs are non-negotiable
Routing video files through Next.js is a bottleneck and a body size limit problem. Direct browser-to-R2 upload is the only sane approach at scale.

3. Whisper accuracy degrades with noise
Clear audio gets 95%+ accuracy. Background music, multiple speakers, or poor microphones drop accuracy fast. Set user expectations accordingly.

4. FFmpeg filter_complex gets complicated fast
Start simple. Get one operation working perfectly before chaining operations. I spent a week debugging a filter_complex string that silently produced corrupted output.

5. Store processing time per stage
Knowing whether transcription or rendering is your bottleneck is
essential for optimization. Store transcriptionTimeSeconds and renderTimeSeconds separately.


The Result

BatchEdits processes 50 videos simultaneously with this architecture. A batch of 12 talking-head clips that would take hours manually finishes in minutes.

If you want to connect this to your
AI agent, the MCP server is open source:
batchedits-mcp-server

Try it free at batchedits.com — first 3 videos, no credit card.


Built by Shayan, solo founder of BatchEdits.
Follow along on X: @shayannadeem123

Top comments (0)