Back to portfolio
Case Study
RabbitHole
An event-driven, AI-augmented video platform on AWS. Upload a clip; an autoscaling fleet transcodes it into adaptive-bitrate HLS, a vision model writes its title and tags, and speech-to-text makes every spoken word searchable - then you stream it back through a CDN with live status the whole way. Shipped continuously through CI/CD, with a dashboard that tracks cost per transcode.
Architecture note: this case study documents an earlier RabbitHole iteration that I implemented and deployed. The live product has continued to evolve, so the current interface and behavior may not map one-for-one to every component described below. The repository preserves the infrastructure and delivery evidence for this architecture.
Built to demonstrate
cloud architecture (event-driven design, a serverless + container
hybrid, autoscaling-to-zero, real-time, cost-awareness),
AI integration (vision + speech
+ semantic search, three different invocation patterns), and
delivery maturity
(tests, OIDC-based CI/CD, remote-state IaC, observability) - all in Terraform.
Current product: Under Construction
GitHub
The problem
A streaming service is a textbook asynchronous workload: uploads are fast, but transcoding is slow and bursty. That mismatch is exactly what event-driven, autoscaling infrastructure exists to solve - so RabbitHole is built to demonstrate that architecture rather than fake it with a CRUD app. The goal: take a raw upload all the way to adaptive playback through a fully decoupled pipeline that costs nothing when no one is using it - then layer real AI on top of it without compromising that.
What it does
- Direct-to-S3 upload - the browser requests a presigned URL and PUTs the file straight to S3; the API never proxies the bytes.
- Async transcode - the S3 upload emits an event that fans out through EventBridge and SQS to Fargate workers running ffmpeg, producing a multi-rendition HLS ladder.
- Adaptive playback - hls.js streams the renditions from CloudFront, switching quality to match the viewer's bandwidth.
- AI auto-metadata - leave the title blank and Claude vision samples frames across the clip and writes a punchy, accuracy-guarded title, description, and tags.
- Speech-to-text + searchable captions - AWS Transcribe turns audio into caption cues; the watch page gets a searchable transcript, click-to-jump, and real WebVTT captions.
- Cross-video semantic search - a local embedding model indexes every transcript; search a phrase and it ranks the best moment in each video by meaning, then deep-links the player to that timestamp.
- Real-time status - a DynamoDB Stream drives a broadcaster Lambda that pushes Transcoding → Ready over a WebSocket, with no polling.
- Public / unlisted visibility, cost dashboard - owners publish public or unlisted; each transcode's Fargate cost is measured and surfaced.
Architecture
An event-driven pipeline that decouples the fast path (upload) from the slow path (transcode), with a serverless API and a container worker fleet - the right tool for each job. The AI work hangs off that same backbone: synchronous vision at the worker, async event-driven speech-to-text, and an embedding model inside the API.
Upload → transcode → stream
1 · presigned URL
API Gateway → LambdaFastAPI · container image
2 · PUT file
3 · ObjectCreated event
EventBridge → SQS+ DLQ · retries
4 · poll · autoscale 0→N
ECS Fargate workersffmpeg · Graviton · HLS
HLS renditions
S3 - streamingprivate · OAC
CloudFrontadaptive playback → UI
Real-time path DynamoDB (videos) → Stream → Broadcaster Lambda → API Gateway WebSocket → live status in the UI
AI metadata worker samples frames → Claude vision → title / description / tags (key in SSM SecureString)
Speech & search worker → AWS Transcribe → EventBridge → post-processor Lambda → caption cues; a local embedding model in the API indexes transcripts for cross-video semantic search
Highlights
- Deterministic scale-to-zero - a scale-up Lambda pins the autoscaling floor while work is queued; a scale-down Lambda releases it only when an idle alarm confirms the queue is empty. A stale metric can never strand a job, yet idle cost is still ~$0 (no running tasks, no NAT gateway). This replaced a naïve setup whose race could reap a freshly-queued job.
- Two AI invocation patterns - synchronous Claude vision for metadata (multi-frame, accuracy-guarded) and async, event-driven AWS Transcribe for captions. The worker fires the transcription job and returns, so transcode latency is untouched.
- Semantic search without a vector-DB bill - a small embedding model (bge-small / ONNX) is baked into the API; vectors live in DynamoDB and a brute-force cosine ranks moments. At this scale it's instant and ~free, deliberately avoiding a managed vector store (≈$700/mo) and keeping the whole thing scale-to-zero.
- Event-driven decoupling - EventBridge → SQS (with a DLQ and retries) fully separates upload from transcode: resilient to worker failure, idempotent, fan-out-ready.
- Serverless + container hybrid - Lambda runs the lightweight API; Fargate (ARM64/Graviton) runs the long-running, CPU-heavy ffmpeg. Each layer uses the right compute model.
- Private by default - CloudFront with Origin Access Control keeps the streaming bucket fully private; secrets live in SSM SecureString, never in code or state.
How it ships & runs
The operational layer is part of the project, not an afterthought - it's what turns "a deployed app" into something you can actually run and evolve.
- CI - every push/PR runs the test suites (pytest + moto for the API and caption pipeline, Vitest for the frontend) behind a coverage gate, plus an image build and
terraform validate.
- CD, no static keys - a push to main assumes a least-privilege role via GitHub OIDC, builds the ARM64 images on native ARM runners, rolls out the Lambda and Fargate service, and publishes the frontend with a CDN invalidation.
- Infra through CI - Terraform state lives in a versioned, encrypted S3 bucket with native state locking; the pipeline plans on merge and applies via a deliberate manual gate (auto-applying infra is intentionally avoided).
- Observability + FinOps - a CloudWatch dashboard tracks queue depth, worker load, API latency (p95) and errors, plus a custom worker metric that surfaces $ per transcode; X-Ray traces the serverless path.
- Hardened - scoped CORS, API-gateway rate limiting, reserved admin identity, and rotated credentials before sharing.
Engineering decisions & trade-offs
Lambda API + Fargate workers
Right tool per job: serverless for the lightweight, bursty API; containers for the long-running, CPU-heavy ffmpeg transcode that would never fit Lambda's runtime and size limits.
AWS Transcribe over self-hosted Whisper
Managed and event-driven (job-complete → EventBridge → Lambda), with native word-level timestamps. It keeps the worker lean and the transcode path fast; the trade is per-minute cost versus running a model in-container.
A local embedding model, not a managed vector DB
Search embeds with a small ONNX model baked into the API and runs brute-force cosine over vectors in DynamoDB. At portfolio scale that's instant and costs nothing; OpenSearch or pgvector would be the move at a far larger corpus - and would also reintroduce idle cost the rest of the system works hard to avoid.
Direct-to-S3 upload (presigned)
The API issues a presigned URL and the browser uploads straight to S3, so the API never proxies file bytes - cheaper, faster, and far friendlier to a Lambda execution model.
No NAT gateway (a documented cost trade-off)
Workers run in public subnets with a zero-ingress security group instead of private subnets behind a NAT gateway. That keeps idle cost near zero for a demo; the production trade-off is noted below.
What I'd change at scale
The demo deliberately optimizes for cost and clarity. Honest production trade-offs that remain:
- AWS Elemental MediaConvert instead of self-managed ffmpeg - less operational surface, per-job billing.
- Real multi-user auth (Cognito or a user store) - today it's a single-creator model with an owner field.
- Private subnets + VPC endpoints for the workers; defense-in-depth over the public-subnet demo.
- A GSI on created_at + pagination instead of a Scan for the library listing.
- OpenSearch / pgvector if the search corpus grows past brute-force; a Fargate X-Ray sidecar for worker-level tracing.
- Multi-region streaming origins with latency-based routing; signed URLs / cookies on the streaming bucket.
Stack at a glance
| Layer | Tech |
| Frontend | React + TypeScript (Vite), hls.js → S3 + CloudFront |
| API | FastAPI on Lambda (container image) + API Gateway |
| Workers | ECS Fargate + ffmpeg (ARM64/Graviton), step-autoscaling on SQS depth (min 0) |
| AI / ML | Claude vision (auto-metadata) · AWS Transcribe (captions) · local embeddings (bge-small/ONNX) for semantic search |
| Real-time | DynamoDB Streams → Lambda → API Gateway WebSocket |
| Messaging | SQS + DLQ, EventBridge (S3 + Transcribe events) |
| Data | S3 (uploads + streaming), DynamoDB |
| CDN / secrets | CloudFront (Origin Access Control) · SSM Parameter Store (SecureString) |
| IaC | Terraform (remote S3 state + native locking) |
| Tests | pytest + moto (API + caption pipeline) · Vitest (frontend), coverage-gated |
| CI/CD | GitHub Actions - tests + coverage gate, OIDC-based deploy, infra plan/apply |
| Observability | CloudWatch dashboard (incl. $/transcode) · X-Ray tracing |