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}`));
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();
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));
}
}
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}`);
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" }
);
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" }
);
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" }
);
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" }
);
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" }
);
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" }
);
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" }
);
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" }
);
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);
});
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}`);
}
});
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 });
}
});
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));
}
}
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)