VOD Docs

Pipeline overview

How an uploaded file becomes an adaptive HLS stream, using a Celery workflow and FFmpeg.

Processing a video means downloading the original, inspecting it, generating scrubbing previews, transcoding several renditions, packaging them as HLS and publishing the result to object storage. That takes minutes of CPU per video, so it runs in a Celery worker, never inside an API request.

Animation · placeholder
Animated pipeline: one source file fanning out into seven renditions, then into segments and a master playlist

Short looping animation for the top of the Pipeline section.

The workflow

The pipeline is a single Celery canvas built in backend/app/tasks/workflows.py:

backend/app/tasks/workflows.py
workflow = chain(
    prepare_video.s(video_id),
    generate_storyboard.s(),
    chord(
        group(
            # transcode_quality.s("2160p"),  # 4K, disabled
            transcode_quality.s("1440p"),
            transcode_quality.s("1080p"),
            transcode_quality.s("720p"),
            transcode_quality.s("480p"),
            transcode_quality.s("360p"),
            transcode_quality.s("240p"),
            transcode_quality.s("144p"),
        ),
        on_transcode_complete.s(),
    ),
    segment_videos.s(),
    create_manifest.s(),
    upload_to_minio.s(),
    finalize_processing.s(),
)

When the processing queue dispatches it, it appends one more step, on_pipeline_success, and attaches on_pipeline_failed as the error handler.

Flow diagram · placeholder
Workflow graph: prepare → storyboard → chord(7× transcode) → aggregate → segment → manifest → upload → finalize → on_pipeline_success, with on_pipeline_failed as the error branch

Mark which steps are sequential and which run as the chord's parallel group.

Celery primitives used

PrimitiveMeaningWhere
Signature (.s())A task call that can be passed around. A step's return value becomes the next step's first argument.Every step
Immutable signature (.si())Ignores the previous step's resulton_pipeline_success
chainRun steps one after anotherThe whole pipeline
groupRun tasks independently, possibly in parallelThe seven transcodes
chordA group plus a callback that runs once every task in the group has finished, receiving all their resultsTranscodes → on_transcode_complete

Each step returns a dictionary (video_id, paths, metadata, results) that the next step reads. The chord's callback receives a list of the seven transcode results and aggregates them into one dictionary for the rest of the chain.

Stages at a glance

#TaskStatus writtenDoes
1prepare_videopreparingDownloads the original from MinIO, runs ffprobe, stores metadata
2generate_storyboardgenerating_storyboardSprite sheets + WebVTT for timeline previews (best effort)
3transcode_quality ×7transcodingH.264/AAC MP4 per rendition, skipping any above the source height
4on_transcode_completeaggregatingCollects successful renditions; fails if none succeeded
5segment_videossegmentingCuts each MP4 into 6-second HLS .ts segments
6create_manifestcreating_manifestWrites master.m3u8 listing every rendition
7upload_to_miniouploading_to_storageUploads playlists and segments to the processed bucket
8finalize_processingfinalizing → completedSaves manifest_url and qualities, deletes the work directory
9on_pipeline_success—Releases the processing lock, starts the next queued video

Full details: Stages.

Parallel in theory, sequential in production

The chord lets all seven transcodes run at once, but how many actually run in parallel depends on the worker's concurrency (the number of worker processes):

EnvironmentConcurrencyEffect
Local (docker-compose.local.yml)3Up to three renditions encode at the same time
Production (docker-compose.yml)1Renditions encode one after another

Production is deliberately set to 1. FFmpeg is CPU- and memory-hungry, and the backend shares one small server with the API, database and storage. One encode at a time keeps the API responsive and memory predictable. The trade-off is longer processing time per video.

Where files live during processing

Each video gets a working directory on the worker, /app/tmp/video_processing/<video_id>/:

raw.<ext>

Every stage reads the previous stage's files from this local directory, which is why the whole pipeline for one video runs on the same worker. The directory is deleted by finalize_processing when processing succeeds.

Time limits

Celery is configured (backend/app/celery_app.py) with a 50-minute soft and 60-minute hard time limit per task. A single rendition or upload step that runs longer is interrupted. This matters for long, high-resolution sources on a small server; see Improvements.

On this page