Download Pipeline¶
When users upload a video, import from YouTube, or add b-roll, we process the source into a unified MediaFile: content-addressable storage, extracted audio, a waveform, a thumbnail, and (for non-web-compatible video) a 480p proxy. Every source — main video, b-roll, image, audio — flows through the same worker and lands in one bucket (STORAGE_BUCKET_MEDIA).
The pipeline lives in backend/src/workers/download/media_pipeline/ and runs on two brokers: download_broker for the light artifacts (audio, waveform, thumbnail) and proxy_broker for the CPU-heavy 480p re-encode.
Two Entry Points¶
YouTube Import¶
Admin-only for now (get_current_superuser) while the feature waits on a bot-safe download server. The service fetches metadata, creates the MediaFile + MediaItem at PROCESSING, and — after commit — kicks download_youtube_media. Convention #16: the service manages its own sessions around the metadata fetch; the download itself runs in the worker. Convention #13 applies — the duration cap is enforced against resolved video metadata, not URL shape alone.
Presigned Upload (single-PUT, files < 25 MiB)¶
POST /api/v1/media/presign
{ "filename": "video.mp4", "content_type": "video/mp4", "size_bytes": ..., "scope": "PROJECT", ... }
Returns a presigned URL. The client PUTs the file body directly to R2, then confirms:
Confirm HEADs the object, re-checks quota against the actual Content-Length, then queues process_media_artifacts. This is the main confirm-path task.
Multipart Upload (files ≥ 25 MiB)¶
For files at or above MULTIPART_CUTOFF_BYTES (25 MiB), the client uses the multipart endpoints under /media/multipart/:
POST /media/multipart/initiate → upload_id + initial 50 part URLs
PUT <part URL> (× N parts in parallel)
POST /media/multipart/complete → finalize, kick process_media_artifacts
POST /media/multipart/abort → cancel + release R2 parts
GET /media/multipart/parts → list parts already on R2 (resume)
GET /media/multipart/parts/urls → refill URLs as upload progresses
The complete endpoint queues process_media_artifacts, mirroring the single-PUT confirm path. See docs/backend/api.md Multipart Upload sections for full request/response schemas.
MediaFile Lifecycle¶
MediaFileStatus has six states. The load-bearing distinction: UPLOADED means "streamable + analysis-ready", not "fully processed". The 480p proxy is a non-blocking post-UPLOADED upgrade, tracked by the derived proxy_status (not_applicable / pending / ready / unknown), never a gate.
- Single-PUT / multipart upload:
PENDING → PROCESSING → UPLOADED(orFAILED). Confirm/complete flips toPROCESSINGand kicksprocess_media_artifacts, which finalizes toUPLOADED. - YouTube import:
PROCESSING → DOWNLOADING → PROCESSING → UPLOADED.download_youtube_mediafetches into storage and hands off toprocess_media_artifacts. - Multipart in-flight:
MULTIPART_INITIATED. Rows still in this state are deleted on user cancel, complete-time over-quota, or cron sweep — never flipped toFAILED.
process_media_artifacts Task¶
The confirm-path worker (download_broker). Order is load-bearing:
- Download from R2 — fetch the uploaded source into a temp dir.
- sha256 hash — compute the content hash off the request path (
asyncio.to_thread). - Content-addressable dedup — if the hash matches an existing
UPLOADEDMediaFile(the winner), repoint this row'sMediaItems onto the winner, delete the loser row + its storage object, and short-circuit. Pre-check + UNIQUE-violation race-catch; the winner is readFOR UPDATEso status is ordered against the winner's own completion. See Content-Addressable Dedup. - Build artifacts (
build_media_artifacts, dispatched by content type): - Video — probe codecs + dimensions + duration; extract audio (or synthesize silence for a silent source); generate waveform; generate thumbnail.
- Audio — duration + waveform, no proxy.
- Image — dimensions + thumbnail, no proxy.
- Animated GIF — normalized to MP4 first (
transcode_gif_to_mp4), then re-keyed and processed as an ordinary video. A single-frame GIF stays a static image. - Finalize — write the artifacts (
storage_key,audio_key,waveform_json,has_audio,width,height,thumbnail_key,duration_ms,metadata_json) and flip toUPLOADED. - Seed canvases (
seed_canvases_for_media_file) — grow the canvas spine for each PROJECT-scopedMediaItembacked by this file, so the analysis worker has structure to read. LIBRARY items seed nothing. - Emit
media_ready(emit_media_ready) — one SSE event per referencingMediaItem, carrying the derivedproxy_status. - Kick the proxy — if the source is non-web-compatible video, enqueue
generate_media_proxyontoproxy_broker.
On the final retry attempt, an unrecoverable failure flips the row to FAILED and emits media_failed.
download_youtube_media Task¶
The YouTube analog of the single-PUT confirm. It only makes the MediaFile's storage_key valid, then hands off:
- Download with yt-dlp — up to 1080p (
MAX_VIDEO_HEIGHT), best audio, merged to MP4. - Upload to media storage — store in R2 under
media/{prefix}/{uuid}/{title}.mp4. set_media_source_downloaded— write the storage key and flip toPROCESSING.- Hand off — kick
process_media_artifacts, which owns dedup, artifacts, canvas seeding,media_ready, and the proxy.
Idempotent: an already-UPLOADED row (a re-import that deduped onto it at the endpoint) skips the download and just re-seeds + notifies for the newly added MediaItem.
generate_media_proxy Task¶
Runs on the dedicated proxy_broker (queue: proxy), executed by the taskiq-proxy-worker container. CPU-heavy re-encodes can take 1-3× source duration, so decoupling this broker from download_broker keeps proxy generation off the analysis-feeding queue.
Creates a web-compatible preview plus a timeline scrub sprite:
- Download original — from
STORAGE_BUCKET_MEDIA. - Transcode + sprite — chained FFmpeg single-decode pass: 480p H.264 + AAC audio (with
+faststartand dense keyframes for ~2s seek granularity) plus a 10×20 grid sprite (JPEG) from the same-i.-map 0:a?(optional) so audio-less inputs survive the encode. - Upload both — proxy to
media/{prefix}/{uuid}/proxy.mp4, sprite tomedia/{prefix}/{uuid}/sprite.jpg. set_media_proxy— setproxy_key,sprite_key, andsprite_seconds_per_tile; status staysUPLOADED.- Emit
media_readywithproxy_status = ready.
A failure here does not flip the MediaFile to FAILED — it is already UPLOADED and analysis-ready; the proxy is non-blocking. taskiq retries; if it ultimately gives up, the row stays UPLOADED with proxy_status = pending and the stuck-proxy cron sweep recovers it. Sprite density (seconds_per_tile) is chosen at generation time as max(1, ceil(duration_s / 200)) — short clips get 1s/tile, long clips scale down so the sprite stays a fixed 10×20 grid.
Content-Addressable Dedup¶
MediaFile is content-addressable via sha256_hash (or youtube_video_id), under a UNIQUE constraint. When two uploads have the same bytes, the second becomes a dedup loser:
- Pre-check — an already-
UPLOADEDMediaFilewith this hash exists. Repoint the loser'sMediaItems onto it and delete the loser. - Race-catch —
claim_hashcollides with a concurrent owner that may still bePROCESSING. Same repoint-onto-winner path; the winner is readFOR UPDATE.
The losing hash is never persisted (the UNIQUE constraint rejects the write), so a dedup loser never becomes a dedup target for a third upload — which is what makes inline-deleting it unconditionally safe. Quota is untouched: the loser's confirm-time per-MediaItem charge stands. The loser's storage object is deleted best-effort (infra-only, never touches quota).
Silent-Source Handling¶
MediaFile.has_audio is set by probing the source (audio_codec is None → False). Silent sources (iOS ReplayKit screen recordings, muted b-roll, PNGs, animated GIFs) get a synthesized silent WAV at audio_key so downstream consumers stay shape-stable, but the analysis pipeline routes Whisper / silence / false-start detection around them per-clip. The frontend disables the audio-dependent controls only when every source in the project is silent.
Canvas Seeding¶
Once an artifact finalize (or a dedup repoint onto an already-UPLOADED winner) makes a file analyzable, seed_canvases_for_media_file grows each referencing PROJECT's canvas spine — a SINGLE canvas + its CANVAS_FILL Edit per source — so the analysis worker has structure to read. Idempotent and zero-duration-safe via CanvasService.seed_single_canvas. This is the upload→canvas forward path; the editor owns all subsequent structural mutation.
Recovery¶
There is no per-source retry endpoint. A source that exits at FAILED is recovered by deleting it and re-uploading — a content-addressed re-upload of the same bytes deduplicates onto any surviving copy for free.
Stuck-row recovery is handled by the recover_stuck_tasks cron (_recover_stuck_media_files):
PENDINGpastSTUCK_MEDIA_FILE_MINUTES(abandoned single-PUT) → delete theMediaItem+MediaFile+ R2 object.PROCESSINGpastSTUCK_MEDIA_PROCESSING_MINUTES→FAILED.MULTIPART_INITIATEDpast the multipart window → abort on R2 (idempotent) + delete the rows.UPLOADEDnon-web-compatible withproxy_key IS NULLpastSTUCK_PROXY_MINUTES→FAILED.
Orphan reclamation runs in the daily cleanup_orphan_media_files cron: a MediaFile whose every MediaItem was deleted is reclaimed (row + R2 objects) after ORPHAN_GRACE_DAYS, race-safe under a per-row FOR UPDATE refcount lock.
Audio Extraction¶
We extract audio in Whisper-compatible format (16kHz mono WAV):
-acodec pcm_s16le- uncompressed 16-bit PCM-ar 16000- 16kHz sample rate (Whisper requirement)-ac 1- mono channel
The audio is stored at audio_key and reused by the analysis pipeline. A silent source synthesizes an equivalent-duration silent WAV via anullsrc instead.
Waveform Generation¶
The waveform is an array of peak amplitudes used for the timeline visualization:
We generate ~100 peaks per second of audio. The frontend renders these as vertical bars in the timeline. Stored on MediaFile.waveform_json.
Codec Compatibility¶
Web browsers can play:
- Video: H.264, VP8, VP9
- Audio: AAC, MP3, Opus
If we detect non-compatible codecs (HEVC, ProRes, AV1 in some browsers, animated GIF), we generate a proxy for preview via generate_media_proxy. The original stays intact for rendering. Web-compatible sources skip the proxy entirely (proxy_status = not_applicable).
Storage Keys¶
All artifacts live under one bucket (STORAGE_BUCKET_MEDIA) and one key layout:
media/{prefix}/{uuid}/source # Original upload / download
media/{prefix}/{uuid}/audio.wav # Extracted (or synthesized) audio
media/{prefix}/{uuid}/proxy.mp4 # Web-compatible proxy
media/{prefix}/{uuid}/sprite.jpg # Timeline scrub sprite (10x20 grid)
media/{prefix}/{uuid}/thumbnail.jpg # Thumbnail
media/{prefix}/{uuid}/asset.mp4 # GIF-normalized MP4 (re-keyed)
The {prefix} is the first 2 characters of the UUID, which helps R2 distribute keys across partitions. Keys are minted by generate_media_storage_key.
Key Files¶
| Component | Location |
|---|---|
| Artifact task | backend/src/workers/download/media_pipeline/tasks.py:process_media_artifacts |
| YouTube task | backend/src/workers/download/media_pipeline/tasks.py:download_youtube_media |
| Proxy task | backend/src/workers/download/media_pipeline/tasks.py:generate_media_proxy |
| DB + dedup helpers (Convention #16 shape 2) | backend/src/workers/download/media_pipeline/context.py |
| yt-dlp + audio extraction + codec probes + proxy/sprite + GIF transcode | backend/src/workers/download/media.py |
| Upload / presign / multipart service | backend/src/modules/media/service.py |
| Media endpoints | backend/src/interfaces/api/v1/media.py |
| Waveform generation | backend/src/infrastructure/waveform.py |