DEV Community

RenderIO
RenderIO

Posted on • Originally published at renderio.dev

FFmpeg API with Node.js: Video Processing in 10 Lines

Video processing shouldn't require child_process

The typical Node.js approach to FFmpeg is child_process.spawn(). You shell out to a binary, parse stderr for progress, handle exit codes, and hope the binary exists on your deployment target.

// The old way
const { spawn } = require("child_process");
const ffmpeg = spawn("ffmpeg", ["-i", "input.mp4", "-c:v", "libx264", "output.mp4"]);
ffmpeg.stderr.on("data", (data) => console.log(data.toString()));
ffmpeg.on("close", (code) => console.log(`Exited with code ${code}`));
Enter fullscreen mode Exit fullscreen mode

This works locally. It breaks in production when the FFmpeg binary isn't installed, isn't the right version, or doesn't support the codec you need. If you've compared self-hosted vs cloud FFmpeg, you know the tradeoffs.

Here's the alternative: fetch(). Ten lines, no binary dependency.

const response = await fetch("https://renderio.dev/api/v1/run-ffmpeg-command", {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-API-KEY": "ffsk_your_key" },
  body: JSON.stringify({
    ffmpeg_command: "-i {input} -c:v libx264 -crf 22 -c:a aac {output}",
    input_files: { input: "https://example.com/video.mov" },
    output_files: { output: "converted.mp4" },
  }),
});
const { command_id } = await response.json();
Enter fullscreen mode Exit fullscreen mode

It works everywhere Node.js runs — no binary to bundle, no codec compatibility issues.

Setup

Zero dependencies required. Node.js 18+ has built-in fetch. For older versions, use node-fetch.

const API_KEY = "ffsk_your_api_key";
const BASE_URL = "https://renderio.dev/api/v1";

async function runFFmpeg(command, inputFiles, outputFiles) {
  // Submit the command
  const submitRes = await fetch(`${BASE_URL}/run-ffmpeg-command`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-KEY": API_KEY,
    },
    body: JSON.stringify({
      ffmpeg_command: command,
      input_files: inputFiles,
      output_files: outputFiles,
    }),
  });

  if (!submitRes.ok) {
    const error = await submitRes.json();
    throw new Error(`Submit failed: ${JSON.stringify(error)}`);
  }

  const { command_id } = await submitRes.json();

  // Poll for result
  while (true) {
    const statusRes = await fetch(`${BASE_URL}/commands/${command_id}`, {
      headers: { "X-API-KEY": API_KEY },
    });
    const status = await statusRes.json();

    if (status.status === "SUCCESS") return status.output_files;
    if (status.status === "FAILED") throw new Error(`FFmpeg failed: ${status.error}`);

    await new Promise((r) => setTimeout(r, 2000));
  }
}
Enter fullscreen mode Exit fullscreen mode

Every example below uses this runFFmpeg function.

Convert formats

MOV to MP4:

const result = await runFFmpeg(
  "-i {input} -c:v libx264 -preset fast -crf 22 -c:a aac -b:a 128k {output}",
  { input: "https://example.com/video.mov" },
  { output: "converted.mp4" }
);
console.log(`Download: ${result.output}`);
Enter fullscreen mode Exit fullscreen mode

MP4 to WebM:

const result = await runFFmpeg(
  "-i {input} -c:v libvpx-vp9 -crf 30 -b:v 0 -c:a libopus {output}",
  { input: "https://example.com/video.mp4" },
  { output: "web.webm" }
);
Enter fullscreen mode Exit fullscreen mode

Resize for social platforms

TikTok (1080x1920):

const result = await runFFmpeg(
  '-i {input} -vf "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black" -c:v libx264 -crf 23 -c:a aac {output}',
  { input: "https://example.com/video.mp4" },
  { output: "tiktok.mp4" }
);
Enter fullscreen mode Exit fullscreen mode

YouTube Shorts (1080x1920, same dimensions):

const result = await runFFmpeg(
  '-i {input} -vf "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2" -c:v libx264 -crf 23 -c:a aac {output}',
  { input: "https://example.com/video.mp4" },
  { output: "shorts.mp4" }
);
Enter fullscreen mode Exit fullscreen mode

720p for general web use:

const result = await runFFmpeg(
  "-i {input} -vf scale=-2:720 -c:v libx264 -crf 23 -c:a copy {output}",
  { input: "https://example.com/4k-video.mp4" },
  { output: "720p.mp4" }
);
Enter fullscreen mode Exit fullscreen mode

Extract audio

To MP3:

const result = await runFFmpeg(
  "-i {input} -vn -acodec libmp3lame -q:a 2 {output}",
  { input: "https://example.com/video.mp4" },
  { output: "audio.mp3" }
);
Enter fullscreen mode Exit fullscreen mode

Generate thumbnails

For advanced frame extraction — keyframes, scene detection, and batch processing — see the FFmpeg frame extraction guide.

Single frame at 5 seconds:

const result = await runFFmpeg(
  "-i {input} -ss 00:00:05 -vframes 1 -q:v 2 {output}",
  { input: "https://example.com/video.mp4" },
  { output: "thumb.jpg" }
);
Enter fullscreen mode Exit fullscreen mode

Trim video

For keyframe-accurate trimming, batch operations, and the trim filter, see the dedicated trim guide.

30 seconds starting at 1:00:

const result = await runFFmpeg(
  "-i {input} -ss 00:01:00 -t 00:00:30 -c copy {output}",
  { input: "https://example.com/long-video.mp4" },
  { output: "clip.mp4" }
);
Enter fullscreen mode Exit fullscreen mode

Add watermark

const result = await runFFmpeg(
  '-i {video} -i {logo} -filter_complex "overlay=W-w-10:H-h-10" {output}',
  {
    video: "https://example.com/video.mp4",
    logo: "https://example.com/watermark.png",
  },
  { output: "watermarked.mp4" }
);
Enter fullscreen mode Exit fullscreen mode

This is a basic fixed-position overlay. For responsive scaling with scale2ref, semi-transparent logos, text overlays, and moving watermarks, the FFmpeg watermark guide covers it all.

Using webhooks instead of polling

Polling works for scripts and CLIs. For web applications, webhooks are better. Configure a webhook URL in your RenderIO dashboard, then submit without polling:

// Express.js webhook endpoint

const app = express();
app.use(express.json());

// Submit a job (fire and forget)
async function submitJob(inputUrl) {
  const res = await fetch(`${BASE_URL}/run-ffmpeg-command`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-KEY": API_KEY,
    },
    body: JSON.stringify({
      ffmpeg_command: "-i {input} -c:v libx264 -crf 22 {output}",
      input_files: { input: inputUrl },
      output_files: { output: "result.mp4" },
    }),
  });
  const { command_id } = await res.json();
  // Store command_id in your database, associated with the user/job
  return command_id;
}

// Receive webhook when processing completes
app.post("/webhooks/renderio", (req, res) => {
  const { command_id, status, output_files } = req.body;

  if (status === "SUCCESS") {
    // Update your database, notify the user, etc.
    console.log(`Job ${command_id} done: ${output_files.output}`);
  } else if (status === "FAILED") {
    console.error(`Job ${command_id} failed: ${req.body.error}`);
  }

  res.sendStatus(200);
});
Enter fullscreen mode Exit fullscreen mode

Your server just reacts to events instead of burning cycles polling. For a full walkthrough of the submit-poll-download flow, see the REST API tutorial.

Batch processing

Process multiple videos concurrently with Promise.all:

const videos = [
  { url: "https://example.com/v1.mp4", name: "out1.mp4" },
  { url: "https://example.com/v2.mp4", name: "out2.mp4" },
  { url: "https://example.com/v3.mp4", name: "out3.mp4" },
];

const results = await Promise.allSettled(
  videos.map((v) =>
    runFFmpeg(
      "-i {input} -c:v libx264 -crf 23 -c:a aac {output}",
      { input: v.url },
      { output: v.name }
    )
  )
);

results.forEach((result, i) => {
  if (result.status === "fulfilled") {
    console.log(`${videos[i].name}: ${result.value.output}`);
  } else {
    console.error(`${videos[i].name}: ${result.reason.message}`);
  }
});
Enter fullscreen mode Exit fullscreen mode

Promise.allSettled ensures one failure doesn't cancel the rest. Each video processes in its own container. If you're doing this at scale for social media, the batch video processing guide covers the full pipeline.

Express.js integration

A complete Express endpoint that accepts video uploads and returns processed versions:



const app = express();
app.use(express.json());

app.post("/process-video", async (req, res) => {
  const { videoUrl, operation } = req.body;

  const commands = {
    compress: "-i {input} -c:v libx264 -crf 28 -preset fast -c:a aac -b:a 96k {output}",
    thumbnail: "-i {input} -ss 00:00:03 -vframes 1 -q:v 2 {output}",
    audio: "-i {input} -vn -acodec libmp3lame -q:a 4 {output}",
    resize720: "-i {input} -vf scale=-2:720 -c:v libx264 -crf 23 {output}",
  };

  const command = commands[operation];
  if (!command) return res.status(400).json({ error: "Unknown operation" });

  const ext = operation === "thumbnail" ? "jpg" : operation === "audio" ? "mp3" : "mp4";

  try {
    const result = await runFFmpeg(
      command,
      { input: videoUrl },
      { output: `processed.${ext}` }
    );
    res.json({ url: result.output });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

Four operations behind a single endpoint — compress, thumbnail, extract audio, resize. You could add more by extending the commands object.

TypeScript types

If you're using TypeScript, here are the types for the API responses:

interface FFmpegSubmitResponse {
  command_id: string;
  status: "processing" | "queued";
}

interface FFmpegStatusResponse {
  command_id: string;
  status: "processing" | "completed" | "failed";
  output_files?: Record<string, string>;
  error?: string;
}

async function runFFmpeg(
  command: string,
  inputFiles: Record<string, string>,
  outputFiles: Record<string, string>
): Promise<Record<string, string>> {
  const submitRes = await fetch(`${BASE_URL}/run-ffmpeg-command`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-KEY": API_KEY },
    body: JSON.stringify({
      ffmpeg_command: command,
      input_files: inputFiles,
      output_files: outputFiles,
    }),
  });

  if (!submitRes.ok) throw new Error(`Submit failed: ${submitRes.status}`);

  const { command_id } = (await submitRes.json()) as FFmpegSubmitResponse;

  while (true) {
    const statusRes = await fetch(`${BASE_URL}/commands/${command_id}`, {
      headers: { "X-API-KEY": API_KEY },
    });
    const status = (await statusRes.json()) as FFmpegStatusResponse;

    if (status.status === "SUCCESS") return status.output_files!;
    if (status.status === "FAILED") throw new Error(status.error ?? "Unknown");

    await new Promise((r) => setTimeout(r, 2000));
  }
}
Enter fullscreen mode Exit fullscreen mode

FAQ

Does this work with Bun or Deno?

Yes. Both Bun and Deno support fetch natively, so the code works without changes. The only thing to adjust is how you load environment variables for the API key.

Can I use this with Next.js API routes?

Absolutely. The runFFmpeg function works in any server-side Node.js context. In Next.js, call it from an API route or a Server Action — just don't call it from client-side code since that would expose your API key.

How do I handle large files that take minutes to process?

Use webhooks instead of polling. Submit the job, store the command_id in your database, and let RenderIO POST the result to your webhook endpoint when it's done. The webhook section above shows the Express setup.

What's the maximum concurrent requests?

It depends on your plan. The API processes each job in an isolated container, so there's no shared resource bottleneck. For heavy batch workloads, you might want to limit your Promise.all concurrency to avoid hitting rate limits.

Can I use this with the Node.js FFmpeg cheat sheet commands?

Yes — every command from the FFmpeg cheat sheet works. Replace local file paths with URL placeholders ({input}, {output}) and pass them through runFFmpeg.

Get started

For a quick reference of common FFmpeg commands you can use with this setup, check the curl examples. The same commands work in both curl and Node.js.

The Starter plan at $9/mo includes 500 commands. Get your API key to start building.

Top comments (0)