>FFmpegLab Sign In
FFmpegLab Guide

Ultimate Video Onboarding Pipeline – YAML & SQL Triggers on Supabase

Declarative pipeline definition with YAML, automatic SQL generation, and an SVG graph — built on PostgreSQL triggers, pgmq, and Supabase Storage.

Every video platform faces the same problem: users upload raw media, and you need to deliver it in multiple formats, resolutions, and with thumbnails. Doing this manually is a nightmare. Doing it with a serverless, event‑driven architecture is the solution — especially when you combine PostgreSQL triggers, pgmq, Supabase Storage, and ffmpeglab.

This guide shows you how to build the ultimate video onboarding pipeline using a declarative YAML template and a transpiler that generates the complete SQL migration. You get:

Key takeaways

The Gap: Manual Media Processing Is Broken

Most media processing pipelines require manual steps: upload a file, trigger a script, wait for processing, then manually move the file. This is slow, error‑prone, and doesn't scale.

What if the pipeline could be fully automated — triggered by the upload itself, processing in the background, and notifying the user when complete? And what if you could define that pipeline in a declarative YAML file that you can version, share, and reuse?

This guide shows you exactly how to build that pipeline using a YAML‑driven approach.

Architecture Overview

User Uploads File → Supabase Storage → PostgreSQL Triggers → pgmq Queue → ffmpeglab-runner
Processed Media → Public Bucket → pg_notify → User Notified

The pipeline consists of:

Important: This pipeline uses the existing render and logpiece tables from the FFmpegLab server. It does not create new tables — it only adds the pipeline components.

What the Pipeline Delivers

Media TypeOutputLocation
VideoThumbnails: 160×90, 320×180, 640×360video-processed/{userId}/{pipelineId}/{runId}/thumbnails/
VideoResolutions: 480p, 720p, 1080p (MP4)video-processed/{userId}/{pipelineId}/{runId}/videos/
ImageThumbnail: 320×320video-processed/{userId}/{pipelineId}/{runId}/thumbnails/
AllReal‑time notificationspg_notify channels
AllJob trackingrender table (existing)
AllLogslogpiece table (existing)

Prerequisites

The YAML‑Driven Approach

While you can write the SQL directly, the recommended way is to use the YAML transpiler. This gives you:

The transpiler is a single TypeScript file that reads your YAML and generates the SQL migration. It runs with Deno and has zero external dependencies (except yaml for parsing).

The YAML Template

Create a file called video-pipeline.yaml with the following content. It defines the buckets, RLS policies, and each processing step. The runId section configures how the per‑run ID is generated — in this case, deterministically from the input file name.

video-pipeline.yaml
name: "Ultimate Video Onboarding Pipeline"
pipelineId: "video-pipeline"
runId:
  mode: "deterministic"
  template: "{baseFilename}"
description: "Parallel video & image processing: thumbnails, transcodes, all artifacts kept"
version: "1.0.0"

editor:
  compressionLevel: 23
  preset: "medium"
  aspectRatio: "16:9"
  framerate: 30
  opacity: 1.0
  output: "mp4"

storage:
  output_bucket: "video-processed"
  buckets:
    - name: "video-uploads"
      public: false
      allowed_mime_types:
        - "video/mp4"
        - "video/quicktime"
        - "video/webm"
        - "image/jpeg"
        - "image/png"
        - "image/webp"
        - "image/gif"
    - name: "video-processed"
      public: true
      allowed_mime_types:
        - "image/jpeg"
        - "image/png"
        - "video/mp4"

  rls_policies:
    - name: "Users can upload to their own video folder"
      operation: "INSERT"
      role: "authenticated"
      condition: |
        bucket_id = 'video-uploads' AND
        (storage.foldername(name))[1] = auth.uid()::text

    - name: "Users can read their own processed video"
      operation: "SELECT"
      role: "authenticated"
      condition: |
        bucket_id = 'video-processed' AND
        (storage.foldername(name))[1] = auth.uid()::text

    - name: "Public read access to processed video"
      operation: "SELECT"
      role: "anon"
      condition: |
        bucket_id = 'video-processed'

    - name: "Service role can manage processed video"
      operation: "ALL"
      role: "service_role"
      condition: |
        bucket_id = 'video-processed'

steps:
  # ---- Video thumbnails ----
  - id: "thumbnail_160x90"
    trigger:
      name: "handle_thumbnail_160x90"
      event: "INSERT"
      table: "storage.objects"
      condition: |
        NEW.bucket_id = 'video-uploads' AND
        NEW.name NOT LIKE '%.emptyFolderPlaceholder' AND
        (NEW.metadata->>'mimetype' LIKE 'video/%')
    command: -i $MEDIA_1 -vf thumbnail,scale=160:90 -frames:v 1 -f image2 -y $OUTPUT_PATH
    inputs: ["INPUT_FILE"]
    outputs: ["OUTPUT_FILE"]
    output_path: "{{userId}}/{{pipelineId}}/{{runId}}/thumbnails/160x90.jpg"
    editor:
      output: "jpg"
      preset: "fast"
      selectedCode: "custom"
      width: 160
      height: 90
    keep: true

  - id: "thumbnail_320x180"
    trigger:
      name: "handle_thumbnail_320x180"
      event: "INSERT"
      table: "storage.objects"
      condition: |
        NEW.bucket_id = 'video-uploads' AND
        NEW.name NOT LIKE '%.emptyFolderPlaceholder' AND
        (NEW.metadata->>'mimetype' LIKE 'video/%')
    command: -i $MEDIA_1 -vf thumbnail,scale=320:180 -frames:v 1 -f image2 -y $OUTPUT_PATH
    inputs: ["INPUT_FILE"]
    outputs: ["OUTPUT_FILE"]
    output_path: "{{userId}}/{{pipelineId}}/{{runId}}/thumbnails/320x180.jpg"
    editor:
      output: "jpg"
      preset: "fast"
      selectedCode: "custom"
      width: 320
      height: 180
    keep: true

  - id: "thumbnail_640x360"
    trigger:
      name: "handle_thumbnail_640x360"
      event: "INSERT"
      table: "storage.objects"
      condition: |
        NEW.bucket_id = 'video-uploads' AND
        NEW.name NOT LIKE '%.emptyFolderPlaceholder' AND
        (NEW.metadata->>'mimetype' LIKE 'video/%')
    command: -i $MEDIA_1 -vf thumbnail,scale=640:360 -frames:v 1 -f image2 -y $OUTPUT_PATH
    inputs: ["INPUT_FILE"]
    outputs: ["OUTPUT_FILE"]
    output_path: "{{userId}}/{{pipelineId}}/{{runId}}/thumbnails/640x360.jpg"
    editor:
      output: "jpg"
      preset: "fast"
      selectedCode: "custom"
      width: 640
      height: 360
    keep: true

  # ---- Video transcodes ----
  - id: "transcode_480p"
    trigger:
      name: "handle_transcode_480p"
      event: "INSERT"
      table: "storage.objects"
      condition: |
        NEW.bucket_id = 'video-uploads' AND
        NEW.name NOT LIKE '%.emptyFolderPlaceholder' AND
        (NEW.metadata->>'mimetype' LIKE 'video/%')
    command: -i $MEDIA_1 -c:v libx264 -crf 23 -preset medium -vf scale=-2:480 -c:a aac -b:a 128k -movflags +faststart -f mp4 -y $OUTPUT_PATH
    inputs: ["INPUT_FILE"]
    outputs: ["OUTPUT_FILE"]
    output_path: "{{userId}}/{{pipelineId}}/{{runId}}/videos/480p.mp4"
    editor:
      output: "mp4"
      preset: "medium"
      selectedCode: "custom"
      width: 854
      height: 480
      compressionLevel: 23
    keep: true

  - id: "transcode_720p"
    trigger:
      name: "handle_transcode_720p"
      event: "INSERT"
      table: "storage.objects"
      condition: |
        NEW.bucket_id = 'video-uploads' AND
        NEW.name NOT LIKE '%.emptyFolderPlaceholder' AND
        (NEW.metadata->>'mimetype' LIKE 'video/%')
    command: -i $MEDIA_1 -c:v libx264 -crf 23 -preset medium -vf scale=-2:720 -c:a aac -b:a 128k -movflags +faststart -f mp4 -y $OUTPUT_PATH
    inputs: ["INPUT_FILE"]
    outputs: ["OUTPUT_FILE"]
    output_path: "{{userId}}/{{pipelineId}}/{{runId}}/videos/720p.mp4"
    editor:
      output: "mp4"
      preset: "medium"
      selectedCode: "custom"
      width: 1280
      height: 720
      compressionLevel: 23
    keep: true

  - id: "transcode_1080p"
    trigger:
      name: "handle_transcode_1080p"
      event: "INSERT"
      table: "storage.objects"
      condition: |
        NEW.bucket_id = 'video-uploads' AND
        NEW.name NOT LIKE '%.emptyFolderPlaceholder' AND
        (NEW.metadata->>'mimetype' LIKE 'video/%')
    command: -i $MEDIA_1 -c:v libx264 -crf 23 -preset medium -vf scale=-2:1080 -c:a aac -b:a 128k -movflags +faststart -f mp4 -y $OUTPUT_PATH
    inputs: ["INPUT_FILE"]
    outputs: ["OUTPUT_FILE"]
    output_path: "{{userId}}/{{pipelineId}}/{{runId}}/videos/1080p.mp4"
    editor:
      output: "mp4"
      preset: "medium"
      selectedCode: "custom"
      width: 1920
      height: 1080
      compressionLevel: 23
    keep: true

  # ---- Image thumbnail ----
  - id: "image_thumbnail_320x320"
    trigger:
      name: "handle_image_thumbnail"
      event: "INSERT"
      table: "storage.objects"
      condition: |
        NEW.bucket_id = 'video-uploads' AND
        NEW.name NOT LIKE '%.emptyFolderPlaceholder' AND
        (NEW.metadata->>'mimetype' LIKE 'image/%')
    command: -i $MEDIA_1 -vf scale=320:320:force_original_aspect_ratio=decrease,pad=320:320:(ow-iw)/2:(oh-ih)/2 -q:v 85 -f image2 -y $OUTPUT_PATH
    inputs: ["INPUT_FILE"]
    outputs: ["OUTPUT_FILE"]
    output_path: "{{userId}}/{{pipelineId}}/{{runId}}/thumbnails/320x320.jpg"
    editor:
      output: "jpg"
      preset: "fast"
      selectedCode: "custom"
      width: 320
      height: 320
    keep: true

render:
  project_name: "video-processing"
  status: "queued"
  public: false

The keep: true flag tells the transpiler to send the output directly to the final bucket (video-processed). Steps without keep (or with keep: false) use next_bucket for chaining. The runId is computed deterministically from the input file name (using mode: "deterministic" and template: "{baseFilename}"). This ensures all parallel steps compute the same run ID, grouping all outputs for a single upload under one folder.

Running the Transpiler

Download the transpiler and the SVG generator:

# Download transpiler and SVG generator
curl -O https://raw.githubusercontent.com/ffmpeglab/server/main/sdk/yaml/transpiler.ts
curl -O https://raw.githubusercontent.com/ffmpeglab/server/main/sdk/yaml/svg.ts

Run the transpiler to generate the migration files:

# Generate the migration files
deno run --allow-read --allow-write transpiler.ts video-pipeline.yaml ./supabase/migrations

Add the --svg flag to also generate a visual graph of your pipeline:

# Generate migration files + SVG graph
deno run --allow-read --allow-write transpiler.ts video-pipeline.yaml ./supabase/migrations --svg

The output will be:

✅ Migration files created:
UP: ./supabase/migrations/20260807120000_video-pipeline.sql
DOWN: ./supabase/migrations/20260807120000_video-pipeline_down.sql
SVG: ./supabase/migrations/20260807120000_video-pipeline.svg

Apply the migration to your Supabase database:

# Via psql
psql -U postgres -d your_database -f ./supabase/migrations/20260807120000_video-pipeline.sql

Visualising the Pipeline

The generated SVG gives you a clear overview of your pipeline. Steps marked with KEEP are green – their outputs are permanently stored in the final bucket. Edges are labelled with the destination bucket (video-processed), making it easy to see where the processed files end up.

→ video-processed → video-processed → video-processed → video-processed → video-processed → video-processed → video-processed 📤 video-uploads thumbnail_160x90 KEEP 📁 {userId}/video-pipeline/{filename}/thu… 160x90 jpg (fast) thumbnail_320x180 KEEP 📁 {userId}/video-pipeline/{filename}/thu… 320x180 jpg (fast) thumbnail_640x360 KEEP 📁 {userId}/video-pipeline/{filename}/thu… 640x360 jpg (fast) transcode_480p KEEP 📁 {userId}/video-pipeline/{filename}/vid… 854x480 transcode_720p KEEP 📁 {userId}/video-pipeline/{filename}/vid… 1280x720 transcode_1080p KEEP 📁 {userId}/video-pipeline/{filename}/vid… 1920x1080 image_thumbnail_320x320 KEEP 📁 {userId}/video-pipeline/{filename}/thu… 320x320 jpg (fast)

In the graph above, all steps trigger on the same video-uploads bucket and output directly to video-processed. This parallel execution reduces latency and keeps the pipeline simple. The edge labels now correctly show video-processed as the destination bucket.

Processing Logic Explained

When a file is uploaded to video-uploads/{userId}/, the pipeline:

  1. Identifies the media type — video or image (via MIME type in the trigger condition).
  2. Fires all matching triggers — video steps fire for video files, the image step fires for image files.
  3. Computes the run ID — using the file name (deterministic mode), all steps get the same runId.
  4. Builds the exact FFmpeg commands — each step has its own command defined in the YAML.
  5. Creates a render job in the existing render table with the commands in the data column.
  6. Pushes a job to the render queue with the commands payload.
  7. Sends a notification via pg_notify.
  8. The ffmpeglab-runner picks up the job, resolves the $MEDIA_1 and $OUTPUT_PATH placeholders, and executes the commands.

Because all steps share the same runId, all outputs are written to video-processed/{userId}/video-pipeline/{runId}/thumbnails/ and .../videos/, keeping everything together.

Exact FFmpeg Commands

The YAML steps define the following FFmpeg commands using placeholders:

1. Video Thumbnails

# 160x90 thumbnail
ffmpeg -i $MEDIA_1 -vf thumbnail,scale=160:90 -frames:v 1 -f image2 -y $OUTPUT_PATH

# 320x180 thumbnail
ffmpeg -i $MEDIA_1 -vf thumbnail,scale=320:180 -frames:v 1 -f image2 -y $OUTPUT_PATH

# 640x360 thumbnail
ffmpeg -i $MEDIA_1 -vf thumbnail,scale=640:360 -frames:v 1 -f image2 -y $OUTPUT_PATH

2. Video Transcoding

# 480p
ffmpeg -i $MEDIA_1 -c:v libx264 -crf 23 -preset medium -vf scale=-2:480 -c:a aac -b:a 128k -movflags +faststart -f mp4 -y $OUTPUT_PATH

# 720p
ffmpeg -i $MEDIA_1 -c:v libx264 -crf 23 -preset medium -vf scale=-2:720 -c:a aac -b:a 128k -movflags +faststart -f mp4 -y $OUTPUT_PATH

# 1080p
ffmpeg -i $MEDIA_1 -c:v libx264 -crf 23 -preset medium -vf scale=-2:1080 -c:a aac -b:a 128k -movflags +faststart -f mp4 -y $OUTPUT_PATH

3. Image Thumbnail

# 320x320 centered thumbnail with padding
ffmpeg -i $MEDIA_1 -vf scale=320:320:force_original_aspect_ratio=decrease,pad=320:320:(ow-iw)/2:(oh-ih)/2 -q:v 85 -f image2 -y $OUTPUT_PATH

Configure ffmpeglab-runner

The runner needs to be configured to poll the render queue and execute the provided FFmpeg commands. The transpiler uses the existing render queue.

Step 1
Add the queue to your environment
Add the following to your .env file or Docker Compose configuration.
# Render queue (already used by FFmpegLab server)
RENDER_QUEUE_NAME=render
Step 2
Ensure FFmpeg is installed
Ensure FFmpeg is installed and available in the PATH.
# Debian/Ubuntu
apt-get install -y ffmpeg

# Alpine
apk add ffmpeg
Step 3
Restart the runner
After updating the environment, restart the runner service.
docker compose restart ffmpeglab-runner

Monitor the Pipeline

You can monitor the pipeline using SQL queries and notifications.

Step 1
Check queued jobs
Query the render table to see queued jobs.
SELECT * FROM "render"
WHERE status = 'queued'
ORDER BY created_at DESC;
Step 2
Check render status
Query the existing render table for job status.
SELECT id, title, status, progress, data
FROM "render"
WHERE project = 'video-pipeline'
ORDER BY created_at DESC;
Step 3
Listen to notifications
In your application, listen for real‑time updates.
-- In your PostgreSQL client:
LISTEN render_status_channel;
LISTEN log_channel;
Step 4
Check processed files
List all processed files in the public bucket.
SELECT name, metadata, created_at FROM storage.objects
WHERE bucket_id = 'video-processed'
ORDER BY created_at DESC;

Customising the Pipeline

Add a New Thumbnail Size

Add a new step with the desired dimensions:

- id: "thumbnail_1280x720"
trigger:
name: "handle_thumbnail_1280x720"
event: "INSERT"
table: "storage.objects"
condition: |
NEW.bucket_id = 'video-uploads' AND
NEW.name NOT LIKE '%.emptyFolderPlaceholder' AND
(NEW.metadata->>'mimetype' LIKE 'video/%')
command: -i $MEDIA_1 -vf thumbnail,scale=1280:720 -frames:v 1 -f image2 -y $OUTPUT_PATH
inputs: ["INPUT_FILE"]
outputs: ["OUTPUT_FILE"]
output_path: "{{userId}}/{{pipelineId}}/{{runId}}/thumbnails/1280x720.jpg"
editor:
output: "jpg"
preset: "medium"
keep: true

Add a New Resolution

- id: "transcode_2160p"
trigger:
name: "handle_transcode_2160p"
event: "INSERT"
table: "storage.objects"
condition: |
NEW.bucket_id = 'video-uploads' AND
NEW.name NOT LIKE '%.emptyFolderPlaceholder' AND
(NEW.metadata->>'mimetype' LIKE 'video/%')
command: -i $MEDIA_1 -c:v libx264 -crf 23 -preset medium -vf scale=-2:2160 -c:a aac -b:a 128k -movflags +faststart -f mp4 -y $OUTPUT_PATH
inputs: ["INPUT_FILE"]
outputs: ["OUTPUT_FILE"]
output_path: "{{userId}}/{{pipelineId}}/{{runId}}/videos/2160p.mp4"
editor:
output: "mp4"
preset: "medium"
keep: true

Sequential Pipelines (e.g., Audio)

For pipelines that must run in order, use next_bucket and omit keep (or set keep: false).

steps:
- id: "extract_audio"
command: -i $MEDIA_1 -ac 1 -ar 16000 -vn -f wav -y $OUTPUT_PATH
output_path: "{{userId}}/temp/{{baseFilename}}.wav"
next_bucket: "audio-temp-1"
keep: false

- id: "normalize_loudness"
trigger:
condition: NEW.bucket_id = 'audio-temp-1'
command: -i $MEDIA_1 -af loudnorm=I=-16:LRA=11:TP=-1.5 -c:a libmp3lame -b:a 192k -f mp3 -y $OUTPUT_PATH
output_path: "{{userId}}/podcast/{{baseFilename}}.mp3"
next_bucket: "audio-processed"
keep: true

Frequently Asked Questions (FAQ)

What does the Ultimate Video Onboarding Pipeline do?

It automatically processes uploaded videos and images. For videos, it generates thumbnails (160x90, 320x180, 640x360) and transcodes to multiple resolutions (480p, 720p, 1080p). For images, it creates thumbnails (320x320). All processed files are stored in a public bucket under the user's ID with real-time notifications.

What FFmpeg commands are used for processing?

The pipeline uses ffmpeg with specific commands: for video thumbnails: ffmpeg -i input.mp4 -vf 'thumbnail,scale=W:H' -frames:v 1 output.jpg. For video transcoding: ffmpeg -i input.mp4 -c:v libx264 -crf 23 -preset medium -vf 'scale=-2:H' -c:a aac -b:a 128k output.mp4. For images: ffmpeg -i input.jpg -vf 'scale=320:320:force_original_aspect_ratio=decrease,pad=320:320:(ow-iw)/2:(oh-ih)/2' -q:v 85 output.jpg.

Where are processed files stored?

All processed files are stored in the video-processed bucket under the user's ID, organized in subfolders: thumbnails/ for image and video thumbnails, and videos/ for resized video versions.

Can I customize the thumbnail sizes and video resolutions?

Yes. The pipeline is designed to be configurable. You can modify the steps in the YAML file or the arrays in the SQL trigger function to match your needs.

How is the pipeline triggered?

A PostgreSQL trigger fires on INSERT into storage.objects when a file is uploaded to the video-uploads bucket. It pushes a job to the pgmq queue, which is processed by the ffmpeglab-runner.

Does this pipeline create new tables?

No. The pipeline uses the existing render and logpiece tables from the FFmpegLab server. It only adds storage buckets, RLS policies, the pgmq queue, and the trigger function — no table conflicts.

Final Word

You now have the Ultimate Video Onboarding Pipeline — a fully automated, event‑driven media processing system that turns a single upload into a complete media package. With PostgreSQL triggers, pgmq, and Supabase Storage, you get:

The pipeline is production‑ready, scalable, and configurable. It uses the existing render and logpiece tables from the FFmpegLab server, so there are no table conflicts — just pure, automated media processing.