ABR Video Transcoding Pipeline
Adaptive-bitrate transcoding and packaging that aligns I-frames across renditions so bandwidth switches do not freeze playback.
At a glance
- I-frame alignment across every rendition
- HLS packaging with consistent segment boundaries
- FFmpeg-based transcoding driven from Go
- S3-compatible storage for renditions and manifests
- Job tracking in PostgreSQL with Redis coordination
From the repository
README
Turn one uploaded master into a GOP-locked, multi-bitrate HLS ladder — published atomically, with zero 404s.
📖 Overview
Most "video transcoding" demos produce four MP4 files and call it done. Real players need more: every rendition must place its I-frames on the exact same timeline, or a bandwidth switch freezes the picture and desyncs audio. And the moment a player can see master.m3u8, it will immediately request every segment the manifest references — so publishing a manifest before its chunks exist guarantees a wall of 404s.
This project is the production-grade version of that pipeline, written in Go with no web framework and no transcoding service in the middle:
| 🎯 GOP-locked ladder | Every rung encodes with -g 60 -keyint_min 60 -sc_threshold 0 plus a forced keyframe expression, so I-frames land on an identical 2-second grid across all renditions. Scene cuts cannot inject extra keyframes. |
| 🔒 Zero-404 publishing | Segments are uploaded and HEAD-verified before the manifest is staged. The live master.m3u8 is swapped with a single CopyObject — readers observe the old body or the new one, never a torn one. |
| ⚡ Direct-to-storage ingest | The API reserves an object key and returns a presigned PUT. Media bytes never transit the gateway, so the API stays fast at 20 GiB per file. |
| 🔁 Real failure recovery | Visibility-timeout claims, heartbeats, stale-job recovery, exponential backoff parked in Redis sorted sets, a dead-letter queue, and a quarantine path for undecodable media. |
| 🛡️ Sandboxed FFmpeg | Every child process is capped with prlimit (Linux) or a Job Object (Windows), killed as a process group, and bounded by a watchdog at 2× the probed media duration. |
✨ Core Features
- 🧬 Strict keyframe alignment —
GOP = round(fps × segmentDuration), forced with both discrete flags and-force_key_frames expr:gte(t,n_forced*2)as belt-and-braces. - 📐 Four-rung ABR ladder — 1080p / 720p / 480p / 360p, validated at runtime: even dimensions for
yuv420p, strictly descending bitrate,maxrate ≥ b:v, legal H.264 profiles. - 🔽 Upscaling guard —
SelectLadderdrops every rung above the source height, so a 720p source never gets a pointless 1080p encode. - 📦 fMP4 / CMAF packaging —
init.mp4+seg_%05d.m4s,-hls_flags independent_segments, each media playlist validated locally before upload. - 🧾 Custom master compiler — emits
#EXT-X-STREAM-INFwithBANDWIDTH,AVERAGE-BANDWIDTH,RESOLUTION,FRAME-RATEand a correctCODECSstring per rung, sorted by bandwidth descending. - 🗄️ Auditable artifacts — every rendition's segment count and byte total is persisted to
video_renditions, and the uploaded byte count is compared against the local file size. - 🔍 Deep media probing —
ffprobeJSON parsing with a rational-tolerant decoder, rotation metadata, display-dimension correction, B-frame detection, and keyframe timestamp extraction for verification. - 🪝 Signed webhooks — completion/failure events signed with
HMAC-SHA256inX-ABR-Signature, exponential backoff to 4 attempts, redirects deliberately not followed so the signature never leaks. - 🧹 Path-traversal defence — client filenames are sanitized to a bare base name with control characters and reserved separators stripped before they become part of an object key.
🏗️ System Architecture
🔍 Component breakdown
1. Ingestion plane — cmd/api_gateway
A single net/http server on :8080 built from the stdlib ServeMux. It applies migrations on boot, waits for the object store, and exposes six API routes plus two health probes. It never proxies media bytes — Ingest inserts the video_assets row before signing so the upload key is reserved even if the client never follows through, then returns a presigned PUT valid for 15 minutes.
| Route | Method | Behaviour |
|---|---|---|
/v1/assets | POST | Reserves a key, returns 201 with asset_id, upload_url, upload_key. Rejects bodies over 20 GiB with 413, and validates webhook_url scheme. |
/v1/assets/{id}/transcode | POST | Returns 202. Rejects with 409 upload_incomplete if the master object is not yet in storage — the classic race where a client enqueues before its upload lands. |
/v1/assets/{id} | GET | Asset status plus a renditions[] array (label, bitrate, segment count, size, playlist key). |
/v1/jobs/{id} | GET | Job status, retry budget, and live percent / stage overlaid from the Redis progress hash. |
/v1/assets/{id}/playback | GET | Mints a presigned GET (6 h TTL). 409 not_ready if nothing has been published. |
/v1/queue | GET | Queue depth, active claims, dead-letter depth, parked retries. |
/healthz · /readyz | GET | 200 / 503 after probing Postgres, Redis and the object store. |
2. Work plane — cmd/transcode_worker
Wires the JobProcessor to a worker pool and runs two maintenance loops. The handler wraps Process so job outcomes reach Postgres and the customer webhook no matter how the worker later exits, using a detached context. On SIGTERM it drains for WORKER_SHUTDOWN_GRACE (30 s) — a wedged encode cannot hold the process open, because the FFmpeg watchdog and the queue visibility timeout let another worker recover the abandoned job.
3. Media engine — internal/ffmpeg
Three focused units, each independently testable:
probe.go— wrapsffprobe -print_format json, decoding the many waysffprobeemits numbers (bare or quoted,N/A,0/0). Normalizes rotation to{0, 90, 180, -90}and swaps display dimensions for ±90.KeyframeTimestampsre-probes with-skip_frame nokeyso alignment is verified against real media, not assumed.orchestrator.go— runs the binary with pipes, parsestime=/fps=/bitrate=/speed=frames into aProgressstruct, keeps the last 40 stderr lines for diagnostics, and enforcesWatchdogTimeout = 2 × media duration(30 min floor). Error precedence is deliberate: timeout beats context-cancellation beats exit code.packager.go— invokes the HLS muxer for fMP4 packaging and compiles the master playlist.ValidateMediaPlaylistre-reads the emitted playlist from disk and confirms the#EXTM3Uheader, a resolvable non-empty#EXT-X-MAPinit segment, at least one segment URI,#EXT-X-ENDLIST, and version ≥ 3.
4. Sandbox — internal/ffmpeg/sandbox_{unix,windows}.go
Build-tagged, and both variants enforce the same ResourceLimits:
- Linux —
prlimit --as=<bytes> --cpu=<seconds>,Setpgidfor process-group isolation,SIGKILLto-pidwith single-PID fallback. Ifprlimitis absent the runner degrades to no rlimits and says so, rather than pretending. - Windows — a Job Object with
JOB_OBJECT_LIMIT_JOB_MEMORY,JOB_OBJECT_LIMIT_JOB_TIME, and alwaysKILL_ON_JOB_CLOSE; termination viaTerminateJobObject.
5. Queue plane — internal/queue
No Redis Streams, no BRPOPLPUSH — a purpose-built structure that keeps every recovery path explicit:
| Key | Type | Role |
|---|---|---|
abr:transcode:priority / abr:transcode:pending | list | LPUSH on enqueue; BRPOP across both lists in one call so priority selection is atomic |
abr:transcode:active | hash | field=jobID → now + visibilityTimeout; the claim |
abr:transcode:backoff:<jobID> | zset | score = unix millis due-time, member = payload |
abr:transcode:dlq | list | terminal failures |
abr:quarantine | hash | undecodable sources, copied to abr/quarantine/<ts>/<key> |
abr:transcode:progress:<jobID> | hash | percent / stage, 24 h TTL |
A maintenance loop promotes due retries every 5 s; a second recovers jobs whose claim expired (backoff base 5 s, capped at 15 min). Progress reporting is deliberately best-effort — a Redis hiccup must never fail a healthy encode.
6. Atomic publisher — internal/storage/atomic_writer.go
The component that makes the zero-404 claim true. Publish is strictly two-phase:
StageManifest—PutObjecttomaster.m3u8.tmp. The live key is never written by aPutObject.VerifySegments—HEADevery referenced key, rejecting both absent and zero-byte chunks, and collecting all failures into one error rather than failing on the first.CopyObject— the atomic swap onto the live key.ObjectSizecheck — the committed body's length is compared against the staged bytes.
With VerifyRemotely the verification runs twice — once at stage time and again immediately before the swap — which closes the window where a segment could vanish mid-publish. A Commit without a prior stage fails closed with "stage before commit".
📌 Design note on
CopyObject: S3 overwrite is a single server-side operation, so a reader never observes a truncate-then-rewrite window the way a filesystemPUTwould.
7. Persistence — internal/repository
pgxpool with migrations applied from ./migrations in lexical order, each file in its own transaction together with its schema_migrations row, so a failure rolls back exactly one migration. MarkJobStatus stamps started_at only on the first real transition.
Stage legality is enforced in Go, not in SQL: JobStage.Advance permits self-transitions and exactly one step forward, rejecting regressions and skips — so a late status update can never overwrite a job that has already moved past that stage.
🔐 Trust boundaries
| Boundary | What crosses it | Enforcement |
|---|---|---|
| Internet → API | JSON metadata, ≤ 1 MiB body | io.LimitReader, DisallowUnknownFields, UUID path validation, filename sanitization before it becomes an object key |
Internet → media-raw | Raw, untrusted media bytes | Only ever written through a presigned PUT the API minted. This is the highest-risk surface — the worker treats anything it finds here as hostile. |
| API → object store | Credentials | Server-side only; clients hold nothing but short-lived scoped URLs |
| Worker → FFmpeg | Shell-free argv | No shell interpolation — args are passed as a vector. A missing source is classified as corruption, not retried forever. |
| Pipeline → customer | Webhook payloads | HMAC-SHA256 over the raw body, constant-time compare, redirects disabled to avoid signature leakage |
🔄 Core Workflow — Ingestion to Playback
Step-by-step
- Reserve —
Ingestgenerates the asset UUID, sanitizes the filename, and inserts thevideo_assetsrow with a server-derived key. The row exists before the signature does, so an abandoned upload leaves an auditableUPLOAD_PENDINGrecord rather than nothing. - Upload — the client
PUTs media directly tomedia-raw. This is the trust boundary; the gateway stays out of the data path entirely. - Gate —
TranscodeHEADs the master before queueing. Enqueuing work for an object that has not landed yet is the single most common cause of wasted transcode capacity, so it is rejected with409 upload_incomplete. - Claim — the worker
BRPOPs across the priority and normal lists in a single call, thenHSETs an expiry into the active hash. A heartbeat refreshes it every 30 s while the job runs. - Probe —
ffprobeyields duration, frame rate, display geometry (rotation-corrected) and codec.UpdateAssetProbepersists the real numbers, andSelectLaddertrims the ladder to what the source can actually justify. - Encode — for each surviving rung, libx264 runs with locked GOP flags.
IsCorruptedMediainspects stderr to distinguish "bad input file" (permanent) from "transient" (retryable), so a truncated upload dead-letters immediately instead of burning three attempts. - Package — the HLS muxer emits fMP4 chunks; the generated media playlist is validated from disk before anything is uploaded.
- Upload & verify — segments go up via parallel multipart (4 workers × 8 MiB parts), and each uploaded byte count is compared against the local file size. A short upload aborts the job.
- Publish — the master is staged to
.tmp, all segments areHEAD-verified a second time, and oneCopyObjectcommits it live. This is the step that makes the zero-404 guarantee hold. - Announce — Postgres records
COMPLETED/READY, the claim is acknowledged, and the signed webhook fires on a detached context.
🔁 Failure & recovery paths
| Condition | Classification | Outcome |
|---|---|---|
| Corrupt / undecodable master | Permanent | Copied to abr/quarantine/<ts>/, dead-lettered, asset FAILED, transcode.failed webhook |
| Master object absent | Permanent | Dead-lettered without a pointless retry loop |
| FFmpeg watchdog exceeded | Retryable | Parked in the backoff zset, then re-promoted |
| Transient object-store error | Retryable | Same; capped at max_retries (default 3) |
| Worker crashes mid-job | Recovered | Claim expiry lapses → RecoverStale requeues |
| Client abandons upload | Recovered | Row stays UPLOAD_PENDING; nothing was ever queued |
Job stage progression is a strict forward-only machine:
QUEUED ──▶ PROBING ──▶ ENCODING ──▶ PACKAGING ──▶ COMPLETED
│ │ │ │
└───────────┴────────────┴─────────────┴──▶ FAILED
(any stage, via FailJob SQL)
Advance accepts self-transitions and single forward steps only, so a delayed status write can never move a finished job backwards. Note that FAILED is deliberately not reachable through Advance — it is written by FailJob SQL, keeping failure an explicit, audited act rather than an ordinary transition.
🧰 Tech Stack
| Layer | Technology | Notes |
|---|---|---|
| Language | Go 1.27 | Stdlib-first: net/http, ServeMux, os/exec — no web framework |
| Database | PostgreSQL 16 · pgx/v5 | Pooled, per-migration transactions, ON DELETE CASCADE |
| Queue | Redis 7 · go-redis/v9 | Lists + hashes + sorted sets, AOF on, noeviction |
| Object storage | S3 API · AWS SDK v2 | Path-style addressing; multipart upload at 4 × 8 MiB |
| Media | FFmpeg / ffprobe | libx264, AAC, HLS fMP4 (CMAF) |
| Local infra | Docker Compose | Postgres + Redis + S3-compatible store |
| Testing | testing, httptest | 42 cases across 4 suites; see Testing |
The compose file defaults to
rustfs/rustfsbecause it implements the same S3 surface — identical endpoint paths and AWS SigV4 auth — as theminio/minioimage referenced in the design. Switching back is a one-line image change with no code or configuration edits.
📁 Project Structure
abr-pipeline/
├── cmd/
│ ├── api_gateway/ # :8080 HTTP server — ingest, status, presigned URLs
│ ├── transcode_worker/ # distributed worker — polls queue, drives FFmpeg
│ ├── migrate/ # apply ./migrations/*.up.sql
│ ├── bootstrap/ # idempotently create media-raw + media-processed
│ └── mediagen/ # synthetic benchmark clips (ffmpeg lavfi)
│
├── internal/
│ ├── domain/ # pure business rules — no I/O, no dependencies
│ │ ├── job.go # JobStage machine, retry budget, validation
│ │ └── ladder.go # ABR profiles, GOP math, FFmpeg arg builders
│ ├── ffmpeg/ # media engine
│ │ ├── probe.go # ffprobe JSON → MediaInfo
│ │ ├── orchestrator.go # process runner, progress parsing, watchdog
│ │ ├── packager.go # HLS fMP4 muxer + master playlist compiler
│ │ ├── sandbox_unix.go # prlimit + process-group isolation
│ │ └── sandbox_windows.go # Job Object equivalent
│ ├── storage/
│ │ ├── s3_client.go # multipart upload, presigning, readiness probe
│ │ └── atomic_writer.go # stage → verify → CopyObject (zero-404 core)
│ ├── queue/
│ │ ├── producer.go # enqueue, claim, backoff, DLQ, quarantine
│ │ └── consumer.go # worker pool, heartbeats, stale recovery
│ ├── repository/
│ │ ├── db.go # pool + migration runner
│ │ └── job_repo.go # asset / job / rendition persistence
│ ├── pipeline/
│ │ ├── processor.go # end-to-end job execution
│ │ └── outcome.go # DB + webhook outcome reporting
│ └── transport/
│ ├── http/ # routes.go · handlers.go
│ └── webhook/ # HMAC dispatcher with backoff
│
├── migrations/ # 000000_migrations_table · 000001_video_schema
├── simulator/ # media_generator.go — synthetic test clips
├── tests/ # gop_alignment · atomic_manifest · transcode_worker · e2e
├── docs/
│ └── architecture.md # diagrams, invariants & verification matrix
├── testdata/ # (generated fixtures are gitignored)
│
├── docker-compose.yml # postgres:16 · redis:7 · rustfs
├── Makefile # GNU make entry points
├── make.ps1 # PowerShell mirror (make is not on Windows)
├── go.mod
└── .env # committed local defaults
The dependency direction is strictly inward: cmd/ wires everything, internal/transport and internal/pipeline depend on internal/domain, and internal/domain imports nothing but the standard library. The GOP and ladder rules are therefore unit-testable without a database, a broker, or FFmpeg on the machine.
📚 Full design notes — every invariant, the Redis key layout, the trust-boundary table and a complete verification matrix — are in docs/architecture.md.
🚀 Quick Start
Prerequisites
- Go 1.27+
- Docker (with Compose v2)
- FFmpeg and ffprobe on
PATH— the only hard dependency for the media tests
1. Clone and configure
git clone https://github.com/Kavryx-Dev/adaptive-bitrate-abr-video-transcoding-packaging-pipeline.git
cd abr-pipeline
A .env with working local defaults is committed. Docker Compose reads it automatically; the Go binaries read the process environment via os.Getenv, and every setting also has a code-level default that matches Compose — so on a stock machine no export is required.
Optional: tune configuration
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL | postgres://abr:abr_secret@localhost:5432/abr?sslmode=disable | Postgres DSN |
S3_ENDPOINT | http://localhost:9000 | S3-compatible endpoint |
BUCKET_RAW / BUCKET_PROCESSED | media-raw / media-processed | Bucket names |
API_PORT | 8080 | Gateway listen port |
PUBLIC_BASE_URL | http://localhost:9000 | Prefix for published playback URLs |
WORKER_CONCURRENCY | 2 | Parallel jobs per worker |
SEGMENT_DURATION_SECONDS | 2 | Nominal HLS segment length |
JOB_TIMEOUT_MULTIPLIER | 2 | Watchdog = multiplier × media duration |
FFMPEG_MEMORY_LIMIT_MB | 4096 | Per-process address-space cap |
WEBHOOK_SECRET | (unset) | HMAC key; unset disables signing |
WEBHOOK_MAX_ATTEMPTS | 4 | Webhook delivery attempts |
2. Start infrastructure
make up # docker compose up -d → postgres:16, redis:7, rustfs
make bootstrap # create media-raw + media-processed buckets (idempotent)
make migrate # apply ./migrations/*.up.sql
Windows (PowerShell)
./make.ps1 up
./make.ps1 bootstrap
./make.ps1 migrate
3. Generate test media
make generate-test-video # 30s 720p clip → ./testdata/generated
go run ./cmd/mediagen -preset all # short + standard + long benchmark set
go run ./cmd/mediagen -corrupt # also emit an undecodable fixture
4. Run the pipeline
go run ./cmd/api_gateway # terminal 1 — :8080
go run ./cmd/transcode_worker # terminal 2 — needs WORKER_ID if running replicas
5. Drive it end to end
# 1. Reserve a key and get a presigned upload URL
curl -sX POST localhost:8080/v1/assets \
-H 'Content-Type: application/json' \
-d '{"filename":"clip.mp4","size_bytes":10485760,"webhook_url":"https://example.com/hook","auto_transcode":false}'
# 2. Upload the bytes straight to object storage
curl -sX PUT "<upload_url>" --data-binary @testdata/generated/testsrc_30s_720p.mp4
# 3. Queue the transcode
curl -sX POST localhost:8080/v1/assets/<asset_id>/transcode
# 4. Poll progress (percent and stage come from Redis)
curl -s localhost:8080/v1/jobs/<job_id>
# 5. Collect renditions and a playback URL
curl -s localhost:8080/v1/assets/<asset_id>
curl -s localhost:8080/v1/assets/<asset_id>/playback
Build binaries
make build # → ./bin/api_gateway and ./bin/transcode_worker
🧪 Testing
make test # migrate + bootstrap, then the full suite
make test-short # skips the four long encode + upload cases (tests/*.go, -short)
go vet ./...
The suite is organised around the invariants that actually break players:
| Suite | What it proves |
|---|---|
gop_alignment_test | Keyframe cadence is identical across all four rungs, verified against real encoded media — every keyframe within 20 ms of the 1080p baseline and of each 2 s segment boundary. Also proves -sc_threshold 0 suppresses keyframes at scene cuts, and that the watchdog actually kills a hanging encode. |
atomic_manifest_test | Publishing is refused when segments are missing or zero-length, the live key stays absent after a refused publish, Commit fails closed without a stage, a staged v2 never disturbs a live v1, uploaded byte counts match local sizes, the manifest graph resolves end to end, and 8 concurrent readers across 10 republications never observe a torn body. |
transcode_worker_test | Backoff is exponential and capped, priority jobs dequeue first, EnqueueUnique is idempotent, retries park and promote on time, exhaustion dead-letters, the retry decider dead-letters corrupt media on the first attempt, stale claims are recovered, and 20 concurrent producers enqueue without error. |
e2e_pipeline_test | The full HTTP lifecycle — ingest, presigned upload, transcode, READY with a non-empty playback URL, rendition rows with real segment counts, a published master with exactly three EXT-X-STREAM-INF entries for a 720p source, and a signed webhook actually received. Plus path-traversal sanitisation and webhook signature round-trips. |
Dependency-degradation is deliberate: every Postgres, Redis and S3 requirement degrades to t.Skipf, so only FFmpeg is mandatory. That keeps unit-level tests runnable anywhere while the full integration matrix runs under Compose.
⚠️ One known flake:
TestConcurrentReadersNeverSeeTruncatedManifesthas intermittently failed with an S3 CRC32 mismatch originating in the local object store's read path under concurrent overwrite — not in the publish logic, which isCopyObject-based. It reproduces rarely and passes on re-run.
📄 Design Notes
Why not a framework? The gateway is seven routes over net/http. Adding a web framework would buy routing sugar and cost a dependency tree, an upgrade treadmill, and a layer of magic around ServeMux's path-parameter handling — which this project already uses.
Why not Redis Streams? Streams bring consumer groups and pending-entry lists, but they also bring XAUTOCLAIM semantics that must be tuned to avoid silent message loss. Explicit lists plus an active-claim hash make the recovery path readable in about thirty lines and trivially testable — which is exactly what the queue tests exercise.
Why -force_key_frames and discrete GOP flags? -g/-keyint_min/-sc_threshold 0 define the cadence; the forced-keyframe expression re-anchors it in wall-clock time. Both are emitted so a rounding difference in frame rate cannot let one rung drift by a frame.
Why stage to .tmp at all? Because the failure mode is asymmetric. A manifest that lags its segments costs a retry; a manifest that leads them costs a user-visible 404 wall on a live stream.
📄 License
This project is provided as-is for portfolio and educational use. Add a LICENSE file before distributing.
Built with Go, FFmpeg, Redis, PostgreSQL and a healthy distrust of truncated manifests.