ADR-002: Video Streaming — Bunny Stream HLS + MP4 Fallback¶
Status: Accepted Date: 2026-02-09 Deciders: Dmitry Aleshkovskiy, Claude (AI assistant) Jira: AF-98
Context¶
App Factory needs a video delivery strategy for short micro-lesson content (30-120 seconds). The app is offline-first, requiring both streaming and download capabilities. Videos are uploaded by content editors through Django admin and consumed by Flutter mobile clients.
Key requirements: - Short videos (30-120 sec) per lesson - Offline playback (must work without network) - Adaptive streaming for varying network conditions - CDN-backed delivery with global reach - Automatic transcoding (editors upload source video, system handles the rest) - Cost-effective for a bootstrapped product - Flutter-compatible (iOS + Android)
Options Considered¶
1. Raw MP4 download only¶
- Pros: Simplest approach, trivial offline support, no adaptive complexity
- Cons: No adaptive bitrate (wastes bandwidth or buffers on slow networks), single resolution, large file sizes for high quality
2. Self-hosted HLS (FFmpeg transcoding + S3/CDN)¶
- Pros: Full control, standard protocol, adaptive bitrate
- Cons: Must build and maintain transcoding pipeline (FFmpeg workers, queue, storage for multiple renditions), significant DevOps overhead, higher infrastructure cost
3. AWS MediaConvert + CloudFront¶
- Pros: Managed transcoding, tight AWS integration
- Cons: Complex pricing, vendor lock-in, requires AWS account setup, no built-in player analytics
4. Bunny Stream (Bunny.net)¶
- Pros: Fully managed transcoding (upload MP4 → auto-generate HLS + multiple MP4 resolutions), global CDN included, simple HTTP API, affordable pricing (~$1/1000 minutes), built-in thumbnail generation, MP4 fallback URLs for offline download, token authentication for content protection
- Cons: Smaller ecosystem than AWS, no SLA for enterprise (adequate for current scale)
Decision¶
Bunny Stream for video hosting and delivery, with HLS adaptive streaming for online playback and MP4 fallback for offline download.
Rationale¶
-
Zero transcoding infrastructure: Upload source MP4 via API → Bunny automatically transcodes to 240p, 360p, 480p, 720p, 1080p. No FFmpeg workers, no queue management, no storage planning.
-
Short content advantage: For 30-120 sec videos, HLS adaptive streaming provides fast start without buffering, while MP4 files remain small enough (~5-15 MB at 720p) for practical offline download.
-
Dual delivery model:
- Online: HLS via
https://vz-{id}.b-cdn.net/{videoId}/playlist.m3u8— adaptive bitrate, fast start -
Offline: MP4 via
https://vz-{id}.b-cdn.net/{videoId}/play_720p.mp4— download for local playback -
Flutter compatibility: Standard
video_playerpackage supports HLS natively on both iOS (AVPlayer) and Android (ExoPlayer). No special SDK needed. -
Content protection: Bunny CDN Token Authentication V2 (SHA256) with directory tokens protects HLS streams — single token covers playlist.m3u8 + all .ts segments.
-
Cost efficiency: ~$1 per 1000 minutes of video delivery. For an MVP with hundreds of users and short videos, monthly cost is negligible.
-
Unified CDN provider: Already using Bunny Storage for images/audio — single vendor simplifies billing, credentials, and operational overhead.
Architecture¶
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Django Admin │────>│ Bunny Stream │────>│ Flutter Client │
│ (upload MP4) │ │ (transcode) │ │ (HLS playback) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │ │
AssetUploadService Auto-generates: video_player +
→ create video - playlist.m3u8 chewie (HLS)
→ upload binary - play_240p.mp4 │
→ poll encoding - play_360p.mp4 Offline mode:
- play_480p.mp4 download MP4 →
- play_720p.mp4 local playback
- play_1080p.mp4
- thumbnail.jpg
URL Patterns¶
| Purpose | URL Pattern |
|---|---|
| HLS Adaptive | https://vz-52e5090d-8c8.b-cdn.net/{videoId}/playlist.m3u8 |
| MP4 720p | https://vz-52e5090d-8c8.b-cdn.net/{videoId}/play_720p.mp4 |
| Thumbnail | https://vz-52e5090d-8c8.b-cdn.net/{videoId}/thumbnail.jpg |
| Signed HLS | https://vz-52e5090d-8c8.b-cdn.net/bcdn_token={token}&expires={ts}/{videoId}/playlist.m3u8 |
Encoding Workflow¶
- Admin uploads video file in Django admin
AssetUploadServicecalls Bunny Stream API:POST /library/{id}/videos→PUT /library/{id}/videos/{guid}- Bunny transcodes automatically (status 1→2→3→4)
- Celery periodic task polls
GET /library/{id}/videos/{guid}untilstatus=4(Finished) - On completion: update
MediaAsset.urlwith HLS URL, storebunny_video_id, dimensions, duration
Packages¶
Backend¶
httpx>=0.28.0 # HTTP client for Bunny API
Frontend¶
dependencies:
video_player: ^2.9.2 # HLS + MP4 playback
chewie: ^1.8.5 # Video player UI controls
Consequences¶
- All video content is hosted on Bunny Stream (library ID 596480)
- Videos are NOT stored locally on the Django server — only metadata in PostgreSQL
MediaAssetmodel gainsbunny_video_idandencoding_statusfields- API responses include HLS URL (signed, 2h expiry) for streaming and MP4 URL for offline download
- Flutter uses
video_playerwith HLS URLs — no Bunny-specific SDK - Encoding is asynchronous — video is not immediately available after upload (typically 1-5 min for short clips)
- Bunny.net becomes a critical vendor dependency for video delivery
References¶
- Bunny Stream API
- Bunny Stream Quickstart
- Bunny CDN Token Authentication V2
- Flutter video_player HLS support
- App Factory architecture:
/docs/architecture.md