DEV Community

Javid Jamae
Javid Jamae

Posted on • Originally published at ffmpeg-micro.com

Auto-Process Video Uploads in Supabase Storage with FFmpeg Micro

Originally published at ffmpeg-micro.com

Users upload raw video to your Supabase app, and that video just sits there. Uncompressed. No thumbnail. Taking up storage you're paying for. You could process it manually, but that breaks the moment you have more than a handful of uploads per day. What you actually want is automatic: video goes in, compressed version and thumbnail come out, no human in the loop.

TL;DR: Use a Supabase database webhook to trigger an Edge Function on every new upload. The Edge Function calls the FFmpeg Micro API to compress the video and extract a thumbnail, then stores both results back in Supabase Storage.

The Architecture

The flow has three moving parts, all inside your existing Supabase project:

  1. Database webhook fires when a new row hits storage.objects
  2. Edge Function receives the webhook payload, calls FFmpeg Micro
  3. Processed files go back into a separate Supabase Storage bucket

No external queue. No background worker. No infrastructure to manage beyond what Supabase already gives you.

Step 1: Create Your Storage Buckets

You need two buckets in Supabase Storage: one for raw uploads, one for processed output.

In your Supabase dashboard, go to Storage and create:

  • raw-videos (where users upload)
  • processed-videos (where compressed files and thumbnails land)

Set processed-videos to public if you need to serve thumbnails directly. Keep raw-videos private with RLS policies scoped to the uploading user.

Step 2: Write the Edge Function

Create a new Edge Function that handles the webhook trigger and orchestrates the processing:

import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);

const FFMPEG_API_KEY = Deno.env.get("FFMPEG_MICRO_API_KEY")!;

Deno.serve(async (req) => {
  const payload = await req.json();
  const record = payload.record;

  // Only process video files in the raw-videos bucket
  const mime = record.metadata?.mimetype ?? "";
  if (!record.bucket_id?.startsWith("raw-videos") || !mime.startsWith("video/")) {
    return new Response("skipped", { status: 200 });
  }

  // Get a signed URL for the raw video
  const { data: signedUrl } = await supabase.storage
    .from("raw-videos")
    .createSignedUrl(record.name, 3600);

  // Compress the video via FFmpeg Micro
  const transcodeRes = await fetch(
    "https://api.ffmpeg-micro.com/v1/transcodes",
    {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${FFMPEG_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        inputs: [{ url: signedUrl.signedUrl }],
        outputFormat: "mp4",
        preset: { quality: "medium", resolution: "720p" },
      }),
    }
  );

  const job = await transcodeRes.json();

  // Poll until complete (Edge Functions have a 150s timeout on Pro)
  let status = job.status;
  let result = job;
  while (status === "queued" || status === "processing") {
    await new Promise((r) => setTimeout(r, 3000));
    const pollRes = await fetch(
      `https://api.ffmpeg-micro.com/v1/transcodes/${job.id}`,
      { headers: { "Authorization": `Bearer ${FFMPEG_API_KEY}` } }
    );
    result = await pollRes.json();
    status = result.status;
  }

  if (status !== "completed") {
    return new Response(JSON.stringify({ error: "Transcode failed", status }), {
      status: 500,
    });
  }

  // Download the processed video
  const downloadRes = await fetch(
    `https://api.ffmpeg-micro.com/v1/transcodes/${job.id}/download`,
    { headers: { "Authorization": `Bearer ${FFMPEG_API_KEY}` } }
  );
  const { url: downloadUrl } = await downloadRes.json();
  const videoBlob = await fetch(downloadUrl).then((r) => r.blob());

  // Upload compressed video to processed bucket
  const outputName = record.name.replace(/\.[^.]+$/, "-compressed.mp4");
  await supabase.storage
    .from("processed-videos")
    .upload(outputName, videoBlob, { contentType: "video/mp4" });

  return new Response(JSON.stringify({ status: "processed", outputName }), {
    status: 200,
  });
});
Enter fullscreen mode Exit fullscreen mode

Deploy it with supabase functions deploy process-video.

Step 3: Set Up the Database Webhook

In the Supabase dashboard, go to Database > Webhooks and create a new webhook:

  • Table: storage.objects
  • Events: INSERT
  • URL: Your Edge Function URL (from supabase functions list)
  • Method: POST

Every time a file lands in raw-videos, the webhook fires and your Edge Function picks it up.

Step 4: Add Thumbnail Extraction

You can extract a thumbnail frame by adding a second FFmpeg Micro call. Use the -vframes and -ss options to grab a single frame:

// Extract thumbnail at the 5-second mark
const thumbRes = await fetch(
  "https://api.ffmpeg-micro.com/v1/transcodes",
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${FFMPEG_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      inputs: [{ url: signedUrl.signedUrl }],
      outputFormat: "mp4",
      options: [
        { option: "-ss", argument: "5" },
        { option: "-vframes", argument: "1" },
        { option: "-f", argument: "image2" },
      ],
    }),
  }
);
Enter fullscreen mode Exit fullscreen mode

Upload the resulting image to your processed-videos bucket alongside the compressed video.

Handling Webhooks vs Edge Function Timeouts

Supabase Edge Functions on the Pro plan have a 150-second execution limit. Most video compressions under 5 minutes of source footage complete well within that window. For longer videos, use FFmpeg Micro's webhook feature instead of polling: pass a webhookUrl pointing at a second Edge Function that handles the completed job, downloads the output, and stores it.

This decouples the upload trigger from the processing completion, so you're never blocked by long encodes.

Common Pitfalls

  • Forgetting to filter by bucket. Without the bucket check in your Edge Function, you'll trigger processing on every file upload across your entire project, including profile pictures and documents.
  • Using the service role key client-side. The SUPABASE_SERVICE_ROLE_KEY must only live in Edge Function environment variables, never in client code or browser bundles.
  • Not handling duplicate triggers. Supabase webhooks can fire more than once for the same event. Add idempotency by checking if a processed version already exists before starting a new transcode.
  • Ignoring the storage object name. The record.name includes the full path within the bucket. If users upload to nested folders, your output naming logic needs to handle slashes.

FAQ

Does this work on the Supabase free tier?

Edge Functions are available on the free tier with a 2-million-invocations-per-month limit. The 150-second timeout on Pro is the main constraint for longer videos. FFmpeg Micro's free tier gives you enough processing minutes to test the full pipeline.

Can I process audio files too?

Yes. Change the MIME type check to include audio/ and adjust the FFmpeg Micro options for audio-specific operations like format conversion or normalization with the loudnorm filter.

What about very large video files?

For videos over 500MB, upload them directly to FFmpeg Micro using the presigned upload flow (POST /v1/upload/presigned-url, PUT to the URL, POST /v1/upload/confirm) instead of passing a Supabase signed URL. This avoids hitting bandwidth limits on the signed URL download.

How do I monitor failed processing jobs?

Add a processing_status column to a custom table that tracks each upload. Update it when processing starts, succeeds, or fails. Query that table from your dashboard or set up a Supabase Realtime subscription to alert on failures.

FFmpeg Micro handles the video processing so you don't have to run FFmpeg yourself. Sign up for the free tier and wire up your first Supabase auto-processing pipeline in under 10 minutes.

Top comments (0)