DEV Community

Javid Jamae
Javid Jamae

Posted on • Originally published at ffmpeg-micro.com

How to Use FFmpeg with Trigger.dev (No Binary Setup Required)

Originally published at ffmpeg-micro.com.

Trigger.dev's official FFmpeg guide tells you to install the @trigger.dev/build extension, bundle a static FFmpeg binary into your task, and call it with ffmpegPath(). It works. Until your build artifact balloons past 100 MB, your cold starts crawl, and you're debugging codec availability across deployment environments.

You don't need to ship FFmpeg with your Trigger.dev task. Call an FFmpeg API over HTTP instead, and keep your tasks lean.

Quick answer: Replace the @trigger.dev/build FFmpeg extension with HTTP calls to FFmpeg Micro. POST your video URL to /v1/transcodes, poll for completion, and grab the download link. No binary in your build, no cold-start penalty, and it works on any Trigger.dev deployment target.

What breaks when you bundle FFmpeg as a binary

The @trigger.dev/build approach uses ffmpegExtension() to include a static FFmpeg binary in your task's build output. Trigger.dev's own docs walk through this pattern. For simple cases, it's fine. But production workloads expose several problems:

  • Build size bloat. A static FFmpeg binary adds 80-120 MB to your task artifact. Every deploy ships that weight, and every cold start loads it into memory.
  • Cold-start latency. Larger artifacts mean slower cold starts. If your task scales to zero between runs, each invocation pays the binary loading tax.
  • Codec availability. Static builds may lack specific codecs you need. Want libx265 or libvpx-vp9? You're recompiling or hoping the bundled binary includes it.
  • Version management. Pinning FFmpeg versions across environments is your problem. Updates mean rebuilding and redeploying.

None of these issues exist when FFmpeg runs on someone else's infrastructure.

The binary approach (before)

This is what Trigger.dev's guide recommends. You install the extension, configure your build, and call FFmpeg from the task:

// trigger.config.ts
import { ffmpegExtension } from "@trigger.dev/build/extensions";

export default defineConfig({
  build: {
    extensions: [ffmpegExtension()],
  },
});
Enter fullscreen mode Exit fullscreen mode
// tasks/process-video.ts
import { task } from "@trigger.dev/sdk/v3";
import { ffmpegPath } from "@trigger.dev/build/extensions";
import { execSync } from "child_process";

export const processVideoTask = task({
  id: "process-video",
  run: async (payload: { videoUrl: string }) => {
    const inputPath = "/tmp/input.mp4";
    const outputPath = "/tmp/output.mp4";

    // Download the file first
    execSync(`curl -o ${inputPath} "${payload.videoUrl}"`);

    // Run FFmpeg binary
    execSync(`${ffmpegPath()} -i ${inputPath} -vf scale=1280:720 -crf 23 ${outputPath}`);

    // Upload output somewhere...
  },
});
Enter fullscreen mode Exit fullscreen mode

You're managing file I/O, disk space in /tmp, the binary path, and cleanup. The task does too much.

Drop the binary: FFmpeg over HTTP in Trigger.dev

Replace all of that with three HTTP calls. No extension, no binary, no file system management.

import { task } from "@trigger.dev/sdk/v3";

export const processVideoTask = task({
  id: "process-video",
  run: async (payload: { videoUrl: string }) => {
    const API_KEY = process.env.FFMPEG_MICRO_API_KEY;
    const BASE_URL = "https://api.ffmpeg-micro.com";

    // 1. Start the transcode job
    const createRes = await fetch(`${BASE_URL}/v1/transcodes`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        inputs: [{ url: payload.videoUrl }],
        outputFormat: "mp4",
        preset: { quality: "high" },
      }),
    });

    const job = await createRes.json();

    // 2. Poll until complete
    let status = job.status;
    while (status !== "completed" && status !== "failed") {
      await new Promise((r) => setTimeout(r, 5000));

      const pollRes = await fetch(`${BASE_URL}/v1/transcodes/${job.id}`, {
        headers: { Authorization: `Bearer ${API_KEY}` },
      });
      const pollData = await pollRes.json();
      status = pollData.status;
    }

    if (status === "failed") {
      throw new Error(`Transcode failed for job ${job.id}`);
    }

    // 3. Get the download URL
    const downloadRes = await fetch(
      `${BASE_URL}/v1/transcodes/${job.id}/download`,
      { headers: { Authorization: `Bearer ${API_KEY}` } }
    );
    const { url } = await downloadRes.json();

    return { downloadUrl: url };
  },
});
Enter fullscreen mode Exit fullscreen mode

That's the entire task. No trigger.config.ts changes. No build extensions. No /tmp disk management. Your task artifact stays small, cold starts stay fast, and FFmpeg version management is someone else's problem.

Using custom FFmpeg options

The preset field handles common operations, but you can pass raw FFmpeg flags through the options array when you need fine-grained control. Resize to 720p, set a specific CRF, or choose a codec:

body: JSON.stringify({
  inputs: [{ url: payload.videoUrl }],
  outputFormat: "mp4",
  options: [
    { option: "-vf", argument: "scale=1280:720" },
    { option: "-c:v", argument: "libx264" },
    { option: "-crf", argument: "23" },
    { option: "-c:a", argument: "aac" },
    { option: "-b:a", argument: "128k" },
  ],
}),
Enter fullscreen mode Exit fullscreen mode

The outputFormat accepts mp4, webm, or mov. Use -vf for video filters. Note that -filter_complex is not supported. Use -vf for all filter operations.

Handling file uploads in your task

If your Trigger.dev task receives a file that isn't publicly accessible by URL, upload it to FFmpeg Micro first using the presigned upload flow:

// 1. Get a presigned upload URL
const presignRes = await fetch(`${BASE_URL}/v1/upload/presigned-url`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    filename: "input.mp4",
    contentType: "video/mp4",
    fileSize: fileBuffer.byteLength,
  }),
});
const { result } = await presignRes.json();

// 2. PUT the file to the presigned URL
await fetch(result.uploadUrl, {
  method: "PUT",
  headers: { "Content-Type": "video/mp4" },
  body: fileBuffer,
});

// 3. Confirm the upload
const confirmRes = await fetch(`${BASE_URL}/v1/upload/confirm`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    filename: result.filename,
    fileSize: fileBuffer.byteLength,
  }),
});
const confirmed = await confirmRes.json();

// Now use confirmed.result.gcsUrl as your input URL for transcoding
Enter fullscreen mode Exit fullscreen mode

The upload goes directly to cloud storage, not through the API server. Fast even for large files.

Common pitfalls

Checking only for "completed" in your poll loop. If a job fails and you're not checking for "failed", your loop runs until the Trigger.dev task times out. Always check both terminal states.

Mixing options and preset in the same request. If you include both, options takes priority and preset is ignored. Pick one per request.

Storing the signed download URL for later use. Signed URLs expire after 10 minutes. Always call the /download endpoint fresh when you actually need the file.

Using -filter_complex instead of -vf. The FFmpeg Micro API doesn't support -filter_complex. Use -vf for video filter operations.

Polling too aggressively. A 1-second interval wastes API calls. Five seconds works well for most video lengths. A 10-minute 1080p file typically processes in 1-3 minutes.

FAQ

Do I still need @trigger.dev/build for this approach?

No. The API approach uses standard fetch calls. Remove ffmpegExtension() from your trigger.config.ts entirely. No build extensions are needed.

What if my Trigger.dev task times out before the transcode finishes?

Increase the maxDuration on your task definition. Most transcodes finish within a few minutes. For very long videos, consider splitting the polling into a separate task that the first task triggers, or use Trigger.dev's wait utilities.

Can I process videos that require authentication to download?

Yes. Use a pre-signed URL from your storage provider (S3 pre-signed URL, GCS signed URL) as the input URL. FFmpeg Micro downloads the video from whatever URL you provide. For more details, see our guide on FFmpeg authorization headers.

How does pricing compare to running my own FFmpeg binary?

The binary approach costs you compute time on your Trigger.dev infrastructure, plus the cold-start overhead on every invocation. FFmpeg Micro has a free tier with 10 minutes of processing per month, enough to build and test your workflow. Paid plans scale based on usage. For a deeper comparison, see self-hosting FFmpeg vs. using an API.

What video formats are supported?

Input: any format FFmpeg can read, including MP4, WebM, MOV, AVI, and MKV. Output: mp4, webm, or mov. Audio formats (MP3, WAV, AAC) are supported for audio extraction workflows.

If you're already using Trigger.dev for background jobs and want video processing without the binary overhead, grab a free FFmpeg Micro API key and swap out the extension. The TypeScript examples above work as-is. For more on using FFmpeg with TypeScript in general, check out our FFmpeg with TypeScript guide.

Last verified: July 2026. Code examples tested against Trigger.dev SDK v3 and FFmpeg Micro API v1.

Top comments (0)