Video Pipeline
Inhalt
After upload, videos go through a single BullMQ job named transcode in the worker (apps/worker). The five stages below are steps inside that job, not separate jobs, and in the code the thumbnails are extracted after transcoding. The frontend receives no push updates, the Studio polls the status every two seconds.
Pipeline Overview
[1] Intake → [2] Probe → [3] Thumbnails → [4] Transcode → [5] Finalize
| Stage | Where | DB status during |
|---|---|---|
| 1 — Intake | API route POST /api/upload |
PENDING |
| 2 — Probe | job transcode |
PROCESSING |
| 3 — Thumbnails | job transcode |
PROCESSING |
| 4 — Transcode | job transcode |
PROCESSING |
| 5 — Finalize | job transcode |
LIVE (on error FAILED) |
Stage 1: Intake
The browser sends the file in a single request via XMLHttpRequest to POST /api/upload, and the API streams the body straight into MinIO. Resumable uploads (tus.io) are not implemented yet, an interrupted upload starts over.
- The browser measures upload progress itself via
xhr.upload.onprogress - Beforehand the API compares the
Content-Lengthheader withMAX_UPLOAD_SIZE_MB(default 8192) and answers 413 if it is exceeded - The file is checked via magic bytes and stored as
play-raw/[videoId].[ext] - The API then creates the record with status
PENDINGand visibilityPRIVATEand enqueues thetranscodejob
Client → POST /api/upload → API → MinIO play-raw/[videoId].[ext]
↓
BullMQ: transcode job enqueued
Stage 2: Probe
ffprobe runs against the uploaded file to extract technical metadata.
Extracted fields:
| Field | Description |
|---|---|
| Duration | Total length in seconds |
| Resolution | Width × height in pixels |
| Video codec | e.g. h264, hevc, vp9 |
| Bitrate | Total bitrate in kbps |
The extracted data is written to the video record when the job finishes. The source height decides which HLS levels are rendered.
Stage 3: Thumbnails
FFmpeg extracts 5 candidate frames evenly distributed across the video duration.
- Frames are taken at 10, 30, 50, 70 and 90 % of the duration, 1280 px wide, and saved as WebP to
play-thumbs/[videoId]-cand-1.webp…[videoId]-cand-5.webp - The creator can pick the preferred thumbnail in the Studio editor (
/studio/[id]/edit) - If no selection is made, the middle candidate (50 %) is the default
Stage 4: Transcode
FFmpeg converts the source file to HLS (HTTP Live Streaming) with multiple quality variants.
Quality Variants
| Resolution | Video bitrate | Audio bitrate | Condition |
|---|---|---|---|
| 1080p | max. 5 Mbps | 128 kbps | Source height ≥ 1080 |
| 720p | max. 2.8 Mbps | 128 kbps | Source height ≥ 720 |
| 480p | max. 1.2 Mbps | 128 kbps | Always, even for smaller sources |
Variants above the source resolution are skipped. A 480p source only produces 480p. The ladder is fixed in the worker. TRANSCODE_QUALITIES appears in .env.example and the Unraid template but is not read by the code. With TRANSCODE_EXTRA_CODECS=av1 and/or vp9 the worker additionally renders AV1 (libsvtav1) or VP9 (libvpx-vp9) per level at about 30 % lower bitrate. This costs a lot more CPU, the default is empty.
HLS Output Format
- Segment container: fMP4 (fragmented MP4)
- Segment duration: 4 seconds
- Video codec: H.264 (libx264), preset
medium, CRF 22 - Audio codec: AAC, 128 kbps
- Master playlist:
master.m3u8— lists all quality variants with bandwidth hints - Per-quality playlists:
[quality]/playlist.m3u8withinit.mp4andseg-NNN.m4s - Output location: MinIO
play-videos/[videoId]/
Transcode Progress
The job reports coarse progress steps to BullMQ (10, 20, up to 80 across the variants, then 85, 95 and 100 %). The Studio shows no percentage, it polls the status every two seconds while a video is PENDING or PROCESSING.
Stage 5: Finalize
Once all HLS segments are written to MinIO:
- The video record is set to
LIVE, together with duration, resolution, codec, bitrate, thumbnails and format (SHORTfor portrait videos up to 60 seconds, otherwiseLONG) - If
AUTO_CAPTIONS_ENABLED=1, the caption job is enqueued - If the video is public, subscribers get an in-app notification
- The video shows up in Studio on the next poll and in public feeds only if its visibility is
PUBLIC
External Import (yt-dlp)
Creators can import videos from external platforms by providing a URL.
Workflow:
- Creator submits a URL in Studio →
/studio/import - Worker runs
yt-dlpto download the video and extract metadata - Title and description from yt-dlp are copied into the record
- Default visibility:
private— the creator reviews and publishes manually - The downloaded file goes through the normal
transcodejob
Supported sources: any platform supported by yt-dlp (YouTube, Vimeo, and hundreds of others).
Auto-Captions (Whisper) — behind a feature flag
Whisper.cpp integration for automatic speech-to-text transcription.
- Activation:
AUTO_CAPTIONS_ENABLED=1, off by default - Model: set via
WHISPER_MODEL, default/models/ggml-small.bin - Language: the transcode job passes
deas a fixed value, automatic detection is not wired up - Output format: WebVTT track stored in
play-assetsundercaptions/[videoId]/ - Scope: according to the code only the first six HLS segments of the first variant are transcribed, with 4-second segments roughly the first 24 seconds
- Transcription runs as a separate BullMQ job (
caption-generate, concurrency 1) after Stage 5, so it does not block playback availability
Playback
Player: HLS.js (client-side, no plugin required)
- Adaptive bitrate switching: HLS.js monitors bandwidth and switches quality automatically
- Manual quality selection: not found in the player code
- No signed URLs: HLS files sit in the public-read bucket
play-videosand are served via/s3/. Visibility is checked by the API when the video data is fetched - CORS: the repo contains no MinIO CORS configuration. In the all-in-one container everything is loaded same-origin via
/s3/
Storage Layout
All persistent video data lives in MinIO. The bucket structure:
play-raw/
[videoId].[ext] ← source file, retained post-transcode (trim reuses it)
play-videos/
[videoId]/
master.m3u8 ← HLS master playlist
1080p/
playlist.m3u8
init.mp4
seg-000.m4s
seg-001.m4s
...
720p/
playlist.m3u8
...
480p/
...
play-thumbs/
[videoId]-cand-1.webp ← 5 candidate thumbnails
...
[videoId]-cand-5.webp
play-assets/
logo/<uuid>.png ← Instance logo (set in admin)
captions/[videoId]/[lang].vtt