Vibecode Vimeo
track this build5 phases, 15 steps, beginner friendly0%Hosting your own video stopped being exotic: ffmpeg builds the HLS ladder, object storage holds it, a CDN serves it, and hls.js plays it. For a portfolio or a client reel that is a weekend, and the result has no ads, no upsells, and no platform that can change its mind about your account. What you are actually buying from Vimeo is the delivery layer holding up when 4,000 people open the link at once, plus live streaming, captions, analytics, and a player that has been QA'd against every browser, smart TV, and embed context you will never test. Small library, predictable traffic · build it. Anything with a spike in it · the bandwidth bill is the whole argument.
You are building a lean indie version of Vimeo. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Vimeo · indie build A pipeline that transcodes a source video into an adaptive bitrate ladder with ffmpeg, uploads the segments to object storage behind a content delivery network, and serves unlisted embed pages with your own player, so playback adapts to the viewer's connection and the bandwidth bill is one you can read. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Transcoding | ffmpeg producing an HTTP Live Streaming ladder | adaptive streaming is what stops a video buffering on a phone, and ffmpeg does it without a service | | Storage | S3-compatible object storage | segments are small immutable files, which is exactly what object storage is good at | | Delivery | A content delivery network in front of the bucket | video is the one workload where serving from one machine is genuinely inadequate, and egress from a bucket without a network in front is usually the most expensive way to do it | | Player | hls.js with the native player as a fallback | one player covers every browser, using native playback where it exists | | Pages | A small server generating embed and watch pages | unlisted URLs, an accessible player page, and the metadata that makes a link preview correctly | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **ffmpeg with libx264 and HTTP Live Streaming support** · free - Why: It produces the entire ladder. A build without the encoders you need fails at the first transcode. - Get it: brew install ffmpeg, your package manager, or a static build. Confirm the encoder is present rather than assuming. - Verify: ffmpeg -encoders lists libx264 and ffmpeg -formats lists hls - [ ] **An S3-compatible bucket** · storage is cheap; egress is the number that matters and varies enormously between providers - Why: Holds segments and playlists. Providers differ in egress pricing by a large factor, which matters more here than in any other build. - Get it: Create a bucket and credentials scoped to it. Compare egress rates before choosing, because video makes that number the dominant cost. - Verify: A test upload appears and can be fetched - [ ] **A content delivery network in front of the bucket** · varies widely; check whether your bucket and network providers waive egress between them - Why: Video served straight from a bucket is slow for distant viewers and usually the most expensive delivery option available. - Get it: Choose a network that can use your bucket as an origin, configure it, then block direct bucket access. Some providers waive egress between their own bucket and network, which changes the arithmetic substantially. - Verify: A segment fetch shows a cache hit and the bucket refuses direct requests - [ ] **Two source videos, one long** · free - Why: Keyframe alignment, caption drift and cost estimates all need a video long enough to reveal them. A thirty second clip proves nothing. - Get it: Use your own footage. One short for fast iteration and one over twenty minutes for the alignment and caption checks. - Verify: Both are available and the long one exceeds twenty minutes - [ ] **whisper.cpp with a model, optional** (optional) · free, but slow on long videos - Why: Phase 4 only, and only for draft transcripts. Everything else works without it. - Get it: Build whisper.cpp and download a model. - Verify: It transcribes a 16 kHz mono WAV ## Quick start ```sh ffmpeg -i in.mp4 -g 48 -keyint_min 48 -sc_threshold 0 -c:v libx264 -b:v 2500k -maxrate 2675k -bufsize 3750k -hls_time 4 -hls_playlist_type vod -hls_segment_filename '720p_%03d.ts' 720p.m3u8 ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Live streaming, webinars and events. - Publishable-quality automatic captions and chapters. - Deep engagement analytics from an instrumented player. - Digital rights management. - live streaming, webinars, and events - auto captions, chapters, and the accessibility tooling around them - viewer analytics and engagement graphs - OTT, pay-per-view, and the monetization stack - a player QA'd against every browser, smart TV, and embed surface - traffic spikes absorbed by someone else's bandwidth bill If one of those is essential to you, that is the reason to keep paying for Vimeo, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Vimeo The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a video host you own: an adaptive streaming ladder on object storage behind a content delivery network, with your own embed pages to replace Vimeo. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Transcoding: ffmpeg producing an HTTP Live Streaming ladder · adaptive streaming is what stops a video buffering on a phone, and ffmpeg does it without a service - Storage: S3-compatible object storage · segments are small immutable files, which is exactly what object storage is good at - Delivery: A content delivery network in front of the bucket · video is the one workload where serving from one machine is genuinely inadequate, and egress from a bucket without a network in front is usually the most expensive way to do it - Player: hls.js with the native player as a fallback · one player covers every browser, using native playback where it exists - Pages: A small server generating embed and watch pages · unlisted URLs, an accessible player page, and the metadata that makes a link preview correctly ### Data model (create this before Phase 1) - videos(id, title, description, source_key, duration_ms, width, height, state, created_at) - renditions(video_id, height, bitrate_kbps, playlist_key, segment_prefix, bytes) - jobs(id, video_id, state, started_at, finished_at, error) · transcoding is slow and fails, so it needs a state machine - captions(video_id, language, vtt_key, kind) · kind is subtitles or captions, and the distinction matters for accessibility - views(video_id, day, plays, watch_seconds) · derived from delivery logs rather than from a tracking script - Segments are immutable and content-addressed by rendition and index, so a cache never needs invalidating. ### Phase 1 · A ladder that actually adapts Build: Transcode a source into three or four renditions at sensible resolution and bitrate pairs. Produce per-rendition playlists and a master playlist. Keep keyframes aligned across renditions so a player can switch without a visible glitch. Trap: If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. Done when: A master playlist lists every rendition with correct bandwidth and resolution attributes. Forcing a player to switch renditions mid-playback produces no visible glitch. Keyframe timestamps are verified identical across renditions using a probe rather than by eye. Do not build yet: No upload, no delivery, no pages. ### Phase 2 · Storage and delivery Build: Upload playlists and segments to object storage with correct content types and long cache headers on segments. Put a content delivery network in front, and block direct bucket access so all traffic is cached traffic. Trap: Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. Done when: A video plays from the delivery network hostname. A direct request to the bucket is refused. Segment responses show a cache hit on a second request. Segments carry a long cache lifetime and playlists a short one, verified in response headers. Do not build yet: No pages or captions yet. ### Phase 3 · The player and the embed page Build: A watch page and an embed page using hls.js, falling back to native playback where the browser supports it directly. Correct metadata so a shared link previews with a thumbnail. Keyboard accessible controls. Trap: Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. Done when: The watch page plays in a Chromium browser, in Firefox and in Safari, using native playback in Safari. The embed page works inside an iframe on a different origin. A shared link previews with a thumbnail and title. Every control is reachable and operable by keyboard. Do not build yet: No captions or access control yet. ### Phase 4 · Captions, which are not optional Build: Accept caption files, or generate a draft transcript locally with whisper.cpp and require a human pass before publishing. Serve captions as a separate track the player exposes. Distinguish subtitles from captions, because they are different things. Trap: Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. Done when: A caption track displays in the player and can be turned off. A generated transcript is marked as a draft and cannot be published without an explicit review action. Caption timing stays aligned at the end of a long video. Multiple language tracks are selectable. Do not build yet: No access control or analytics yet. ### Phase 5 · Unlisted, private and expiring links Build: Unlisted videos with unguessable identifiers. Signed playback URLs with a short expiry for genuinely private videos. Domain restriction for embeds so a link lifted from your page does not play on someone else's site. Trap: An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate. Done when: An unlisted video is not discoverable through any listing. A private video's segments return a refusal without a valid signature, tested by requesting a segment URL directly. An embed restricted to a domain refuses to play when the page is copied to another origin. A signature expires and playback stops at a segment boundary. Do not build yet: Analytics and operations come last. ### Phase 6 · Costs, analytics and operations Build: Derive play counts and watch time from delivery logs rather than a tracking script. Track egress and compute cost per thousand views. Set a spend alert, because video is the workload where a popular link becomes an expensive week. Trap: A single video going unexpectedly wide can produce a bandwidth bill an order of magnitude above your normal month, and you find out afterwards. Set a spend alert at your provider and a hard limit if one is available, before publishing anything you expect to travel. Done when: Play counts and watch time are derived from delivery logs and match a manual count for a test video. Cost per thousand views is calculated from real egress figures. A spend alert is configured and has been tested by lowering the threshold. Transcoding failures are visible in the job state rather than silent. Do not build yet: Nothing after this. ### Out of scope (and why) - Live streaming, webinars and events, which are a different pipeline with different failure modes and no room to retry. - Automatic captions and chapters at publishable quality, which is why the caption phase requires a human pass. - The engagement analytics of the hosted product, which come from a player instrumented far more deeply than log analysis allows. - Content protection beyond signed URLs. Digital rights management is a licensing relationship, not a feature you add. ### README must contain - The ladder rungs and why keyframes must align across renditions - That the bucket is not directly reachable and everything is served through the delivery network - That Safari plays natively and the JavaScript player is not attached there - That generated transcripts are drafts and cannot be published without review - The measured cost per thousand views, and that a spend alert is configured ===== AGENTS.md ===== # Agent instructions · Vimeo indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: ffmpeg producing an HTTP Live Streaming ladder, S3-compatible object storage, A content delivery network in front of the bucket, hls.js with the native player as a fallback, A small server generating embed and watch pages. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. - Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. - Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. - Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. - An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate. ===== BUILD_PLAN.md ===== # Build plan · Vimeo A pipeline that transcodes a source video into an adaptive bitrate ladder with ffmpeg, uploads the segments to object storage behind a content delivery network, and serves unlisted embed pages with your own player, so playback adapts to the viewer's connection and the bandwidth bill is one you can read. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A ladder that actually adapts One source produces several renditions and a master playlist a player can switch between. ### Steps 1. Choose the ladder deliberately Three or four rungs covering a phone on mobile data through to a desktop. More rungs cost transcoding time and storage for diminishing benefit. Files: `docs/ladder.md` 2. Force aligned keyframes A fixed group of pictures size with scene change detection off, applied identically to every rendition. ```sh ffmpeg -i in.mp4 -g 48 -keyint_min 48 -sc_threshold 0 -c:v libx264 -b:v 2500k -maxrate 2675k -bufsize 3750k -hls_time 4 -hls_playlist_type vod -hls_segment_filename '720p_%03d.ts' 720p.m3u8 ``` 3. Verify alignment with a probe Extract keyframe timestamps per rendition and assert they match. Do not judge this by watching. Files: `scripts/verify_keyframes.sh` ```sh ffprobe -select_streams v -skip_frame nokey -show_entries frame=pkt_pts_time -of csv 720p.m3u8 ``` ### Done when - [ ] The master playlist lists every rendition with correct attributes - [ ] Keyframe timestamps match across renditions, verified by probe - [ ] A forced rendition switch produces no visible glitch - [ ] Each rendition plays standalone ### Watch out - If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. ## Phase 2 · Storage and delivery Segments are served from a cache close to the viewer, and the bucket is not directly reachable. ### Steps 1. Upload with correct headers Segments get a long cache lifetime because they never change. Playlists get a short one because they can. Files: `src/upload.py` 2. Put a delivery network in front Origin is the bucket, and the bucket only accepts requests from the network. Test the direct path is closed. 3. Verify caching behaviour Request a segment twice and check for a cache hit. A network that misses every time is costing you origin egress anyway. ```sh curl -sI https://cdn.example.com/v/abc/720p_001.ts | grep -i cache ``` ### Done when - [ ] Playback works through the delivery network hostname - [ ] Direct bucket requests are refused - [ ] A repeated segment request shows a cache hit - [ ] Segments and playlists have different cache lifetimes ### Watch out - Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. ## Phase 3 · The player and the embed page A link plays anywhere, including on a phone, and can be embedded on another site. ### Steps 1. Detect native support first If the browser can play the stream natively, let it, and do not attach the JavaScript player at all. Files: `static/player.js` 2. Build watch and embed pages Embed is minimal and frameable; watch has the title, description and a share control. Files: `templates/` 3. Add link preview metadata and keyboard controls Open Graph tags with a generated thumbnail, and controls that work without a mouse. Files: `templates/watch.html` ### Done when - [ ] Playback works in three browsers, natively in Safari - [ ] The embed page works in an iframe on another origin - [ ] A shared link previews with a thumbnail - [ ] All controls are keyboard operable ### Watch out - Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. ## Phase 4 · Captions, which are not optional Every video can carry captions, and they display correctly. ### Steps 1. Support caption uploads Standard timed text files, validated on upload, served as separate tracks. Files: `src/captions.py` 2. Generate drafts locally whisper.cpp for a first pass, stored as a draft with a clear state. Files: `src/transcribe.py` 3. Require review before publishing A draft cannot become a published track without a deliberate action. Enforce it in the state machine, not in the interface only. Files: `src/captions.py` ### Done when - [ ] A caption track displays and can be disabled - [ ] A generated draft cannot be published without review - [ ] Timing stays aligned at the end of a long video - [ ] Multiple languages are selectable ### Watch out - Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. ## Phase 5 · Unlisted, private and expiring links A video can be shared with some people and not with everyone. ### Steps 1. Use unguessable identifiers Enough entropy that enumeration is impractical, and no sequential identifiers anywhere in URLs. Files: `src/ids.py` 2. Sign segment requests for private videos The delivery network validates the signature. Test by requesting a segment directly without one. 3. Restrict embeds by referrer or signature Referrer restriction is weak but useful; signed embeds are stronger. Say which you chose and what it does not stop. Files: `src/embed.py` ### Done when - [ ] An unlisted video appears in no listing - [ ] A private video's segment is refused without a signature - [ ] A restricted embed refuses on another origin - [ ] An expired signature stops playback at a segment boundary ### Watch out - An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate. ## Not in this build - Live streaming, webinars and events. - Publishable-quality automatic captions and chapters. - Deep engagement analytics from an instrumented player. - Digital rights management. ## After v1, if you want it - Per-title encoding, choosing bitrates from the content rather than a fixed ladder - Thumbnail sprites for scrub previews, which are cheap and much missed when absent - A second delivery network as a failover origin, once the first one has an outage ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where playlists and segments are stored. BUCKET_NAME=video-segments # Required · secret. Scoped to this bucket only. BUCKET_ACCESS_KEY=replace-with-key # Required · secret. Kept out of the repository. BUCKET_SECRET_KEY=replace-with-secret # Required. The hostname viewers actually fetch from. The bucket hostname must never appear in a playlist. CDN_BASE_URL=https://cdn.example.com # Optional · secret. Signs playback URLs for private videos. Required only if you use private mode. SIGNING_KEY=replace-with-a-long-random-value # Optional. How long a signed playback URL lasts. Long enough for the video, short enough to matter. SIGNED_URL_TTL_SECONDS=3600 # Required. Height and bitrate pairs. Each rung costs transcoding time and storage. LADDER=360:800,720:2500,1080:5000
You are building a lean indie version of Vimeo. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Vimeo · indie build A pipeline that transcodes a source video into an adaptive bitrate ladder with ffmpeg, uploads the segments to object storage behind a content delivery network, and serves unlisted embed pages with your own player, so playback adapts to the viewer's connection and the bandwidth bill is one you can read. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Transcoding | ffmpeg producing an HTTP Live Streaming ladder | adaptive streaming is what stops a video buffering on a phone, and ffmpeg does it without a service | | Storage | S3-compatible object storage | segments are small immutable files, which is exactly what object storage is good at | | Delivery | A content delivery network in front of the bucket | video is the one workload where serving from one machine is genuinely inadequate, and egress from a bucket without a network in front is usually the most expensive way to do it | | Player | hls.js with the native player as a fallback | one player covers every browser, using native playback where it exists | | Pages | A small server generating embed and watch pages | unlisted URLs, an accessible player page, and the metadata that makes a link preview correctly | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **ffmpeg with libx264 and HTTP Live Streaming support** · free - Why: It produces the entire ladder. A build without the encoders you need fails at the first transcode. - Get it: brew install ffmpeg, your package manager, or a static build. Confirm the encoder is present rather than assuming. - Verify: ffmpeg -encoders lists libx264 and ffmpeg -formats lists hls - [ ] **An S3-compatible bucket** · storage is cheap; egress is the number that matters and varies enormously between providers - Why: Holds segments and playlists. Providers differ in egress pricing by a large factor, which matters more here than in any other build. - Get it: Create a bucket and credentials scoped to it. Compare egress rates before choosing, because video makes that number the dominant cost. - Verify: A test upload appears and can be fetched - [ ] **A content delivery network in front of the bucket** · varies widely; check whether your bucket and network providers waive egress between them - Why: Video served straight from a bucket is slow for distant viewers and usually the most expensive delivery option available. - Get it: Choose a network that can use your bucket as an origin, configure it, then block direct bucket access. Some providers waive egress between their own bucket and network, which changes the arithmetic substantially. - Verify: A segment fetch shows a cache hit and the bucket refuses direct requests - [ ] **Two source videos, one long** · free - Why: Keyframe alignment, caption drift and cost estimates all need a video long enough to reveal them. A thirty second clip proves nothing. - Get it: Use your own footage. One short for fast iteration and one over twenty minutes for the alignment and caption checks. - Verify: Both are available and the long one exceeds twenty minutes - [ ] **whisper.cpp with a model, optional** (optional) · free, but slow on long videos - Why: Phase 4 only, and only for draft transcripts. Everything else works without it. - Get it: Build whisper.cpp and download a model. - Verify: It transcribes a 16 kHz mono WAV ## Quick start ```sh ffmpeg -i in.mp4 -g 48 -keyint_min 48 -sc_threshold 0 -c:v libx264 -b:v 2500k -maxrate 2675k -bufsize 3750k -hls_time 4 -hls_playlist_type vod -hls_segment_filename '720p_%03d.ts' 720p.m3u8 ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Live streaming, webinars and events. - Publishable-quality automatic captions and chapters. - Deep engagement analytics from an instrumented player. - Digital rights management. - live streaming, webinars, and events - auto captions, chapters, and the accessibility tooling around them - viewer analytics and engagement graphs - OTT, pay-per-view, and the monetization stack - a player QA'd against every browser, smart TV, and embed surface - traffic spikes absorbed by someone else's bandwidth bill If one of those is essential to you, that is the reason to keep paying for Vimeo, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Vimeo The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a video host you own: an adaptive streaming ladder on object storage behind a content delivery network, with your own embed pages to replace Vimeo. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Transcoding: ffmpeg producing an HTTP Live Streaming ladder · adaptive streaming is what stops a video buffering on a phone, and ffmpeg does it without a service - Storage: S3-compatible object storage · segments are small immutable files, which is exactly what object storage is good at - Delivery: A content delivery network in front of the bucket · video is the one workload where serving from one machine is genuinely inadequate, and egress from a bucket without a network in front is usually the most expensive way to do it - Player: hls.js with the native player as a fallback · one player covers every browser, using native playback where it exists - Pages: A small server generating embed and watch pages · unlisted URLs, an accessible player page, and the metadata that makes a link preview correctly ### Data model (create this before Phase 1) - videos(id, title, description, source_key, duration_ms, width, height, state, created_at) - renditions(video_id, height, bitrate_kbps, playlist_key, segment_prefix, bytes) - jobs(id, video_id, state, started_at, finished_at, error) · transcoding is slow and fails, so it needs a state machine - captions(video_id, language, vtt_key, kind) · kind is subtitles or captions, and the distinction matters for accessibility - views(video_id, day, plays, watch_seconds) · derived from delivery logs rather than from a tracking script - Segments are immutable and content-addressed by rendition and index, so a cache never needs invalidating. ### Phase 1 · A ladder that actually adapts Build: Transcode a source into three or four renditions at sensible resolution and bitrate pairs. Produce per-rendition playlists and a master playlist. Keep keyframes aligned across renditions so a player can switch without a visible glitch. Trap: If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. Done when: A master playlist lists every rendition with correct bandwidth and resolution attributes. Forcing a player to switch renditions mid-playback produces no visible glitch. Keyframe timestamps are verified identical across renditions using a probe rather than by eye. Do not build yet: No upload, no delivery, no pages. ### Phase 2 · Storage and delivery Build: Upload playlists and segments to object storage with correct content types and long cache headers on segments. Put a content delivery network in front, and block direct bucket access so all traffic is cached traffic. Trap: Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. Done when: A video plays from the delivery network hostname. A direct request to the bucket is refused. Segment responses show a cache hit on a second request. Segments carry a long cache lifetime and playlists a short one, verified in response headers. Do not build yet: No pages or captions yet. ### Phase 3 · The player and the embed page Build: A watch page and an embed page using hls.js, falling back to native playback where the browser supports it directly. Correct metadata so a shared link previews with a thumbnail. Keyboard accessible controls. Trap: Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. Done when: The watch page plays in a Chromium browser, in Firefox and in Safari, using native playback in Safari. The embed page works inside an iframe on a different origin. A shared link previews with a thumbnail and title. Every control is reachable and operable by keyboard. Do not build yet: No captions or access control yet. ### Phase 4 · Captions, which are not optional Build: Accept caption files, or generate a draft transcript locally with whisper.cpp and require a human pass before publishing. Serve captions as a separate track the player exposes. Distinguish subtitles from captions, because they are different things. Trap: Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. Done when: A caption track displays in the player and can be turned off. A generated transcript is marked as a draft and cannot be published without an explicit review action. Caption timing stays aligned at the end of a long video. Multiple language tracks are selectable. Do not build yet: No access control or analytics yet. ### Phase 5 · Unlisted, private and expiring links Build: Unlisted videos with unguessable identifiers. Signed playback URLs with a short expiry for genuinely private videos. Domain restriction for embeds so a link lifted from your page does not play on someone else's site. Trap: An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate. Done when: An unlisted video is not discoverable through any listing. A private video's segments return a refusal without a valid signature, tested by requesting a segment URL directly. An embed restricted to a domain refuses to play when the page is copied to another origin. A signature expires and playback stops at a segment boundary. Do not build yet: Analytics and operations come last. ### Phase 6 · Costs, analytics and operations Build: Derive play counts and watch time from delivery logs rather than a tracking script. Track egress and compute cost per thousand views. Set a spend alert, because video is the workload where a popular link becomes an expensive week. Trap: A single video going unexpectedly wide can produce a bandwidth bill an order of magnitude above your normal month, and you find out afterwards. Set a spend alert at your provider and a hard limit if one is available, before publishing anything you expect to travel. Done when: Play counts and watch time are derived from delivery logs and match a manual count for a test video. Cost per thousand views is calculated from real egress figures. A spend alert is configured and has been tested by lowering the threshold. Transcoding failures are visible in the job state rather than silent. Do not build yet: Nothing after this. ### Out of scope (and why) - Live streaming, webinars and events, which are a different pipeline with different failure modes and no room to retry. - Automatic captions and chapters at publishable quality, which is why the caption phase requires a human pass. - The engagement analytics of the hosted product, which come from a player instrumented far more deeply than log analysis allows. - Content protection beyond signed URLs. Digital rights management is a licensing relationship, not a feature you add. ### README must contain - The ladder rungs and why keyframes must align across renditions - That the bucket is not directly reachable and everything is served through the delivery network - That Safari plays natively and the JavaScript player is not attached there - That generated transcripts are drafts and cannot be published without review - The measured cost per thousand views, and that a spend alert is configured ===== AGENTS.md ===== # Agent instructions · Vimeo indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: ffmpeg producing an HTTP Live Streaming ladder, S3-compatible object storage, A content delivery network in front of the bucket, hls.js with the native player as a fallback, A small server generating embed and watch pages. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. - Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. - Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. - Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. - An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate. ===== BUILD_PLAN.md ===== # Build plan · Vimeo A pipeline that transcodes a source video into an adaptive bitrate ladder with ffmpeg, uploads the segments to object storage behind a content delivery network, and serves unlisted embed pages with your own player, so playback adapts to the viewer's connection and the bandwidth bill is one you can read. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A ladder that actually adapts One source produces several renditions and a master playlist a player can switch between. ### Steps 1. Choose the ladder deliberately Three or four rungs covering a phone on mobile data through to a desktop. More rungs cost transcoding time and storage for diminishing benefit. Files: `docs/ladder.md` 2. Force aligned keyframes A fixed group of pictures size with scene change detection off, applied identically to every rendition. ```sh ffmpeg -i in.mp4 -g 48 -keyint_min 48 -sc_threshold 0 -c:v libx264 -b:v 2500k -maxrate 2675k -bufsize 3750k -hls_time 4 -hls_playlist_type vod -hls_segment_filename '720p_%03d.ts' 720p.m3u8 ``` 3. Verify alignment with a probe Extract keyframe timestamps per rendition and assert they match. Do not judge this by watching. Files: `scripts/verify_keyframes.sh` ```sh ffprobe -select_streams v -skip_frame nokey -show_entries frame=pkt_pts_time -of csv 720p.m3u8 ``` ### Done when - [ ] The master playlist lists every rendition with correct attributes - [ ] Keyframe timestamps match across renditions, verified by probe - [ ] A forced rendition switch produces no visible glitch - [ ] Each rendition plays standalone ### Watch out - If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. ## Phase 2 · Storage and delivery Segments are served from a cache close to the viewer, and the bucket is not directly reachable. ### Steps 1. Upload with correct headers Segments get a long cache lifetime because they never change. Playlists get a short one because they can. Files: `src/upload.py` 2. Put a delivery network in front Origin is the bucket, and the bucket only accepts requests from the network. Test the direct path is closed. 3. Verify caching behaviour Request a segment twice and check for a cache hit. A network that misses every time is costing you origin egress anyway. ```sh curl -sI https://cdn.example.com/v/abc/720p_001.ts | grep -i cache ``` ### Done when - [ ] Playback works through the delivery network hostname - [ ] Direct bucket requests are refused - [ ] A repeated segment request shows a cache hit - [ ] Segments and playlists have different cache lifetimes ### Watch out - Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. ## Phase 3 · The player and the embed page A link plays anywhere, including on a phone, and can be embedded on another site. ### Steps 1. Detect native support first If the browser can play the stream natively, let it, and do not attach the JavaScript player at all. Files: `static/player.js` 2. Build watch and embed pages Embed is minimal and frameable; watch has the title, description and a share control. Files: `templates/` 3. Add link preview metadata and keyboard controls Open Graph tags with a generated thumbnail, and controls that work without a mouse. Files: `templates/watch.html` ### Done when - [ ] Playback works in three browsers, natively in Safari - [ ] The embed page works in an iframe on another origin - [ ] A shared link previews with a thumbnail - [ ] All controls are keyboard operable ### Watch out - Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. ## Phase 4 · Captions, which are not optional Every video can carry captions, and they display correctly. ### Steps 1. Support caption uploads Standard timed text files, validated on upload, served as separate tracks. Files: `src/captions.py` 2. Generate drafts locally whisper.cpp for a first pass, stored as a draft with a clear state. Files: `src/transcribe.py` 3. Require review before publishing A draft cannot become a published track without a deliberate action. Enforce it in the state machine, not in the interface only. Files: `src/captions.py` ### Done when - [ ] A caption track displays and can be disabled - [ ] A generated draft cannot be published without review - [ ] Timing stays aligned at the end of a long video - [ ] Multiple languages are selectable ### Watch out - Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. ## Phase 5 · Unlisted, private and expiring links A video can be shared with some people and not with everyone. ### Steps 1. Use unguessable identifiers Enough entropy that enumeration is impractical, and no sequential identifiers anywhere in URLs. Files: `src/ids.py` 2. Sign segment requests for private videos The delivery network validates the signature. Test by requesting a segment directly without one. 3. Restrict embeds by referrer or signature Referrer restriction is weak but useful; signed embeds are stronger. Say which you chose and what it does not stop. Files: `src/embed.py` ### Done when - [ ] An unlisted video appears in no listing - [ ] A private video's segment is refused without a signature - [ ] A restricted embed refuses on another origin - [ ] An expired signature stops playback at a segment boundary ### Watch out - An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate. ## Not in this build - Live streaming, webinars and events. - Publishable-quality automatic captions and chapters. - Deep engagement analytics from an instrumented player. - Digital rights management. ## After v1, if you want it - Per-title encoding, choosing bitrates from the content rather than a fixed ladder - Thumbnail sprites for scrub previews, which are cheap and much missed when absent - A second delivery network as a failover origin, once the first one has an outage ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where playlists and segments are stored. BUCKET_NAME=video-segments # Required · secret. Scoped to this bucket only. BUCKET_ACCESS_KEY=replace-with-key # Required · secret. Kept out of the repository. BUCKET_SECRET_KEY=replace-with-secret # Required. The hostname viewers actually fetch from. The bucket hostname must never appear in a playlist. CDN_BASE_URL=https://cdn.example.com # Optional · secret. Signs playback URLs for private videos. Required only if you use private mode. SIGNING_KEY=replace-with-a-long-random-value # Optional. How long a signed playback URL lasts. Long enough for the video, short enough to matter. SIGNED_URL_TTL_SECONDS=3600 # Required. Height and bitrate pairs. Each rung costs transcoding time and storage. LADDER=360:800,720:2500,1080:5000
You are building a production product version of Vimeo. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Vimeo · product brief ## Problem Hosting your own video stopped being exotic: ffmpeg builds the HLS ladder, object storage holds it, a CDN serves it, and hls.js plays it. For a portfolio or a client reel that is a weekend, and the result has no ads, no upsells, and no platform that can change its mind about your account. What you are actually buying from Vimeo is the delivery layer holding up when 4,000 people open the link at once, plus live streaming, captions, analytics, and a player that has been QA'd against every browser, smart TV, and embed context you will never test. Small library, predictable traffic · build it. Anything with a spike in it · the bandwidth bill is the whole argument. ## Product outcome A video host where playback adapts cleanly across renditions, delivery is cached rather than paid for at bucket egress rates, captions are never published unreviewed, private really means private, and the cost per thousand views is a number you know. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - ffmpeg - object storage (R2, B2, or S3) - a CDN in front of it - a small VPS for the pages - an egress budget you now own ## Explicit non-goals for v1 - Live streaming, webinars and events. - Publishable-quality automatic captions and chapters. - Deep engagement analytics from an instrumented player. - Digital rights management. - live streaming, webinars, and events - auto captions, chapters, and the accessibility tooling around them - viewer analytics and engagement graphs - OTT, pay-per-view, and the monetization stack - a player QA'd against every browser, smart TV, and embed surface - traffic spikes absorbed by someone else's bandwidth bill ## Success criteria - Keyframe alignment is verified by probe across all renditions - Direct bucket access is confirmed refused - Playback is verified in three browsers including native Safari - A private video's segment is confirmed refused without a signature - A spend alert has been tested by triggering it ===== BRIEF.md ===== # Build brief · Vimeo The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a video host you own: an adaptive streaming ladder on object storage behind a content delivery network, with your own embed pages to replace Vimeo. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Transcoding: ffmpeg producing an HTTP Live Streaming ladder · adaptive streaming is what stops a video buffering on a phone, and ffmpeg does it without a service - Storage: S3-compatible object storage · segments are small immutable files, which is exactly what object storage is good at - Delivery: A content delivery network in front of the bucket · video is the one workload where serving from one machine is genuinely inadequate, and egress from a bucket without a network in front is usually the most expensive way to do it - Player: hls.js with the native player as a fallback · one player covers every browser, using native playback where it exists - Pages: A small server generating embed and watch pages · unlisted URLs, an accessible player page, and the metadata that makes a link preview correctly ### Data model (create this before Phase 1) - videos(id, title, description, source_key, duration_ms, width, height, state, created_at) - renditions(video_id, height, bitrate_kbps, playlist_key, segment_prefix, bytes) - jobs(id, video_id, state, started_at, finished_at, error) · transcoding is slow and fails, so it needs a state machine - captions(video_id, language, vtt_key, kind) · kind is subtitles or captions, and the distinction matters for accessibility - views(video_id, day, plays, watch_seconds) · derived from delivery logs rather than from a tracking script - Segments are immutable and content-addressed by rendition and index, so a cache never needs invalidating. ### Phase 1 · A ladder that actually adapts Build: Transcode a source into three or four renditions at sensible resolution and bitrate pairs. Produce per-rendition playlists and a master playlist. Keep keyframes aligned across renditions so a player can switch without a visible glitch. Trap: If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. Done when: A master playlist lists every rendition with correct bandwidth and resolution attributes. Forcing a player to switch renditions mid-playback produces no visible glitch. Keyframe timestamps are verified identical across renditions using a probe rather than by eye. Do not build yet: No upload, no delivery, no pages. ### Phase 2 · Storage and delivery Build: Upload playlists and segments to object storage with correct content types and long cache headers on segments. Put a content delivery network in front, and block direct bucket access so all traffic is cached traffic. Trap: Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. Done when: A video plays from the delivery network hostname. A direct request to the bucket is refused. Segment responses show a cache hit on a second request. Segments carry a long cache lifetime and playlists a short one, verified in response headers. Do not build yet: No pages or captions yet. ### Phase 3 · The player and the embed page Build: A watch page and an embed page using hls.js, falling back to native playback where the browser supports it directly. Correct metadata so a shared link previews with a thumbnail. Keyboard accessible controls. Trap: Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. Done when: The watch page plays in a Chromium browser, in Firefox and in Safari, using native playback in Safari. The embed page works inside an iframe on a different origin. A shared link previews with a thumbnail and title. Every control is reachable and operable by keyboard. Do not build yet: No captions or access control yet. ### Phase 4 · Captions, which are not optional Build: Accept caption files, or generate a draft transcript locally with whisper.cpp and require a human pass before publishing. Serve captions as a separate track the player exposes. Distinguish subtitles from captions, because they are different things. Trap: Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. Done when: A caption track displays in the player and can be turned off. A generated transcript is marked as a draft and cannot be published without an explicit review action. Caption timing stays aligned at the end of a long video. Multiple language tracks are selectable. Do not build yet: No access control or analytics yet. ### Phase 5 · Unlisted, private and expiring links Build: Unlisted videos with unguessable identifiers. Signed playback URLs with a short expiry for genuinely private videos. Domain restriction for embeds so a link lifted from your page does not play on someone else's site. Trap: An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate. Done when: An unlisted video is not discoverable through any listing. A private video's segments return a refusal without a valid signature, tested by requesting a segment URL directly. An embed restricted to a domain refuses to play when the page is copied to another origin. A signature expires and playback stops at a segment boundary. Do not build yet: Analytics and operations come last. ### Phase 6 · Costs, analytics and operations Build: Derive play counts and watch time from delivery logs rather than a tracking script. Track egress and compute cost per thousand views. Set a spend alert, because video is the workload where a popular link becomes an expensive week. Trap: A single video going unexpectedly wide can produce a bandwidth bill an order of magnitude above your normal month, and you find out afterwards. Set a spend alert at your provider and a hard limit if one is available, before publishing anything you expect to travel. Done when: Play counts and watch time are derived from delivery logs and match a manual count for a test video. Cost per thousand views is calculated from real egress figures. A spend alert is configured and has been tested by lowering the threshold. Transcoding failures are visible in the job state rather than silent. Do not build yet: Nothing after this. ### Out of scope (and why) - Live streaming, webinars and events, which are a different pipeline with different failure modes and no room to retry. - Automatic captions and chapters at publishable quality, which is why the caption phase requires a human pass. - The engagement analytics of the hosted product, which come from a player instrumented far more deeply than log analysis allows. - Content protection beyond signed URLs. Digital rights management is a licensing relationship, not a feature you add. ### README must contain - The ladder rungs and why keyframes must align across renditions - That the bucket is not directly reachable and everything is served through the delivery network - That Safari plays natively and the JavaScript player is not attached there - That generated transcripts are drafts and cannot be published without review - The measured cost per thousand views, and that a spend alert is configured ===== ARCHITECTURE.md ===== # Architecture · Vimeo ## Stack | Part | Choice | Why | | --- | --- | --- | | Transcoding | ffmpeg producing an HTTP Live Streaming ladder | adaptive streaming is what stops a video buffering on a phone, and ffmpeg does it without a service | | Storage | S3-compatible object storage | segments are small immutable files, which is exactly what object storage is good at | | Delivery | A content delivery network in front of the bucket | video is the one workload where serving from one machine is genuinely inadequate, and egress from a bucket without a network in front is usually the most expensive way to do it | | Player | hls.js with the native player as a fallback | one player covers every browser, using native playback where it exists | | Pages | A small server generating embed and watch pages | unlisted URLs, an accessible player page, and the metadata that makes a link preview correctly | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Transcode | Producing the ladder with aligned keyframes. | ffmpeg is the tool. The keyframe alignment requirement survives any change of encoder or settings. | | Storage and delivery | Serving segments cheaply and close to viewers. | Providers are interchangeable; the rule that the bucket is never directly reachable is not. | | Player | Playback across browsers. | Any player library. Native playback where it exists must stay, because fighting it produces the worst bugs in this build. | | Captions | Tracks and the review gate. | Transcription engines vary; the requirement that a draft cannot be published unreviewed is an accessibility commitment rather than a workflow preference. | | Access | Unlisted identifiers and signed segments. | The distinction between obscurity and access control must remain visible in whatever replaces this. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `BUCKET_NAME` · required · Where playlists and segments are stored. - `BUCKET_ACCESS_KEY` · required, secret · Scoped to this bucket only. - `BUCKET_SECRET_KEY` · required, secret · Kept out of the repository. - `CDN_BASE_URL` · required · The hostname viewers actually fetch from. The bucket hostname must never appear in a playlist. - `SIGNING_KEY` · optional, secret · Signs playback URLs for private videos. Required only if you use private mode. - `SIGNED_URL_TTL_SECONDS` · optional · How long a signed playback URL lasts. Long enough for the video, short enough to matter. - `LADDER` · required · Height and bitrate pairs. Each rung costs transcoding time and storage. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Vimeo product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: ffmpeg producing an HTTP Live Streaming ladder, S3-compatible object storage, A content delivery network in front of the bucket, hls.js with the native player as a fallback, A small server generating embed and watch pages. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. - Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. - Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. - Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. - An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate. - A single video going unexpectedly wide can produce a bandwidth bill an order of magnitude above your normal month, and you find out afterwards. Set a spend alert at your provider and a hard limit if one is available, before publishing anything you expect to travel. ===== MILESTONES.md ===== # Delivery milestones · Vimeo Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A ladder that actually adapts One source produces several renditions and a master playlist a player can switch between. ### Steps 1. Choose the ladder deliberately Three or four rungs covering a phone on mobile data through to a desktop. More rungs cost transcoding time and storage for diminishing benefit. Files: `docs/ladder.md` 2. Force aligned keyframes A fixed group of pictures size with scene change detection off, applied identically to every rendition. ```sh ffmpeg -i in.mp4 -g 48 -keyint_min 48 -sc_threshold 0 -c:v libx264 -b:v 2500k -maxrate 2675k -bufsize 3750k -hls_time 4 -hls_playlist_type vod -hls_segment_filename '720p_%03d.ts' 720p.m3u8 ``` 3. Verify alignment with a probe Extract keyframe timestamps per rendition and assert they match. Do not judge this by watching. Files: `scripts/verify_keyframes.sh` ```sh ffprobe -select_streams v -skip_frame nokey -show_entries frame=pkt_pts_time -of csv 720p.m3u8 ``` ### Done when - [ ] The master playlist lists every rendition with correct attributes - [ ] Keyframe timestamps match across renditions, verified by probe - [ ] A forced rendition switch produces no visible glitch - [ ] Each rendition plays standalone ### Watch out - If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. ## M2 · Storage and delivery Segments are served from a cache close to the viewer, and the bucket is not directly reachable. ### Steps 1. Upload with correct headers Segments get a long cache lifetime because they never change. Playlists get a short one because they can. Files: `src/upload.py` 2. Put a delivery network in front Origin is the bucket, and the bucket only accepts requests from the network. Test the direct path is closed. 3. Verify caching behaviour Request a segment twice and check for a cache hit. A network that misses every time is costing you origin egress anyway. ```sh curl -sI https://cdn.example.com/v/abc/720p_001.ts | grep -i cache ``` ### Done when - [ ] Playback works through the delivery network hostname - [ ] Direct bucket requests are refused - [ ] A repeated segment request shows a cache hit - [ ] Segments and playlists have different cache lifetimes ### Watch out - Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. ## M3 · The player and the embed page A link plays anywhere, including on a phone, and can be embedded on another site. ### Steps 1. Detect native support first If the browser can play the stream natively, let it, and do not attach the JavaScript player at all. Files: `static/player.js` 2. Build watch and embed pages Embed is minimal and frameable; watch has the title, description and a share control. Files: `templates/` 3. Add link preview metadata and keyboard controls Open Graph tags with a generated thumbnail, and controls that work without a mouse. Files: `templates/watch.html` ### Done when - [ ] Playback works in three browsers, natively in Safari - [ ] The embed page works in an iframe on another origin - [ ] A shared link previews with a thumbnail - [ ] All controls are keyboard operable ### Watch out - Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. ## M4 · Captions, which are not optional Every video can carry captions, and they display correctly. ### Steps 1. Support caption uploads Standard timed text files, validated on upload, served as separate tracks. Files: `src/captions.py` 2. Generate drafts locally whisper.cpp for a first pass, stored as a draft with a clear state. Files: `src/transcribe.py` 3. Require review before publishing A draft cannot become a published track without a deliberate action. Enforce it in the state machine, not in the interface only. Files: `src/captions.py` ### Done when - [ ] A caption track displays and can be disabled - [ ] A generated draft cannot be published without review - [ ] Timing stays aligned at the end of a long video - [ ] Multiple languages are selectable ### Watch out - Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. ## M5 · Unlisted, private and expiring links A video can be shared with some people and not with everyone. ### Steps 1. Use unguessable identifiers Enough entropy that enumeration is impractical, and no sequential identifiers anywhere in URLs. Files: `src/ids.py` 2. Sign segment requests for private videos The delivery network validates the signature. Test by requesting a segment directly without one. 3. Restrict embeds by referrer or signature Referrer restriction is weak but useful; signed embeds are stronger. Say which you chose and what it does not stop. Files: `src/embed.py` ### Done when - [ ] An unlisted video appears in no listing - [ ] A private video's segment is refused without a signature - [ ] A restricted embed refuses on another origin - [ ] An expired signature stops playback at a segment boundary ### Watch out - An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate. ## M6 · Costs, analytics and operations (production only) You know what a view costs and what it would cost if a video did well. ### Steps 1. Derive analytics from logs Segment requests grouped by video give plays and watch time without a tracking script and without consent complications. Files: `src/analytics.py` 2. Calculate real cost per thousand views Egress bytes times your rate, plus storage and transcoding. Publish it in your own documentation so the number is not a surprise. Files: `docs/costs.md` 3. Set and test a spend alert Lower the threshold deliberately and confirm the alert arrives. An untested alert is a hope. ### Done when - [ ] Log-derived play counts match a manual count - [ ] Cost per thousand views is calculated from real figures - [ ] A spend alert has been triggered deliberately and arrived - [ ] A failed transcode is visible in the job state ### Watch out - A single video going unexpectedly wide can produce a bandwidth bill an order of magnitude above your normal month, and you find out afterwards. Set a spend alert at your provider and a hard limit if one is available, before publishing anything you expect to travel. ===== OPERATIONS.md ===== # Operations · Vimeo ## Backup Source videos are irreplaceable and belong in a backup. Renditions are regenerable from sources, so backing them up is optional and usually not worth the storage, provided the transcode settings are version controlled. ## Restore Restore sources and settings, re-transcode, re-upload. Time this once for one video so you know what a full rebuild would cost in hours. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Egress volume against a budget, cache hit ratio, and transcode job failures. A falling cache hit ratio means you are paying origin egress for traffic you thought was cached. ## Incident checklist A video going wide is the incident, and the spend alert is the control. If it fires, the levers are removing the top rung of the ladder, tightening cache lifetimes and, if necessary, taking the video private until you decide. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Keyframe alignment is verified by probe across all renditions - [ ] Direct bucket access is confirmed refused - [ ] Playback is verified in three browsers including native Safari - [ ] A private video's segment is confirmed refused without a signature - [ ] A spend alert has been tested by triggering it ## Launch constraint Do not market omitted Vimeo capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where playlists and segments are stored. BUCKET_NAME=video-segments # Required · secret. Scoped to this bucket only. BUCKET_ACCESS_KEY=replace-with-key # Required · secret. Kept out of the repository. BUCKET_SECRET_KEY=replace-with-secret # Required. The hostname viewers actually fetch from. The bucket hostname must never appear in a playlist. CDN_BASE_URL=https://cdn.example.com # Optional · secret. Signs playback URLs for private videos. Required only if you use private mode. SIGNING_KEY=replace-with-a-long-random-value # Optional. How long a signed playback URL lasts. Long enough for the video, short enough to matter. SIGNED_URL_TTL_SECONDS=3600 # Required. Height and bitrate pairs. Each rung costs transcoding time and storage. LADDER=360:800,720:2500,1080:5000
# Vimeo · indie build A pipeline that transcodes a source video into an adaptive bitrate ladder with ffmpeg, uploads the segments to object storage behind a content delivery network, and serves unlisted embed pages with your own player, so playback adapts to the viewer's connection and the bandwidth bill is one you can read. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Transcoding | ffmpeg producing an HTTP Live Streaming ladder | adaptive streaming is what stops a video buffering on a phone, and ffmpeg does it without a service | | Storage | S3-compatible object storage | segments are small immutable files, which is exactly what object storage is good at | | Delivery | A content delivery network in front of the bucket | video is the one workload where serving from one machine is genuinely inadequate, and egress from a bucket without a network in front is usually the most expensive way to do it | | Player | hls.js with the native player as a fallback | one player covers every browser, using native playback where it exists | | Pages | A small server generating embed and watch pages | unlisted URLs, an accessible player page, and the metadata that makes a link preview correctly | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **ffmpeg with libx264 and HTTP Live Streaming support** · free - Why: It produces the entire ladder. A build without the encoders you need fails at the first transcode. - Get it: brew install ffmpeg, your package manager, or a static build. Confirm the encoder is present rather than assuming. - Verify: ffmpeg -encoders lists libx264 and ffmpeg -formats lists hls - [ ] **An S3-compatible bucket** · storage is cheap; egress is the number that matters and varies enormously between providers - Why: Holds segments and playlists. Providers differ in egress pricing by a large factor, which matters more here than in any other build. - Get it: Create a bucket and credentials scoped to it. Compare egress rates before choosing, because video makes that number the dominant cost. - Verify: A test upload appears and can be fetched - [ ] **A content delivery network in front of the bucket** · varies widely; check whether your bucket and network providers waive egress between them - Why: Video served straight from a bucket is slow for distant viewers and usually the most expensive delivery option available. - Get it: Choose a network that can use your bucket as an origin, configure it, then block direct bucket access. Some providers waive egress between their own bucket and network, which changes the arithmetic substantially. - Verify: A segment fetch shows a cache hit and the bucket refuses direct requests - [ ] **Two source videos, one long** · free - Why: Keyframe alignment, caption drift and cost estimates all need a video long enough to reveal them. A thirty second clip proves nothing. - Get it: Use your own footage. One short for fast iteration and one over twenty minutes for the alignment and caption checks. - Verify: Both are available and the long one exceeds twenty minutes - [ ] **whisper.cpp with a model, optional** (optional) · free, but slow on long videos - Why: Phase 4 only, and only for draft transcripts. Everything else works without it. - Get it: Build whisper.cpp and download a model. - Verify: It transcribes a 16 kHz mono WAV ## Quick start ```sh ffmpeg -i in.mp4 -g 48 -keyint_min 48 -sc_threshold 0 -c:v libx264 -b:v 2500k -maxrate 2675k -bufsize 3750k -hls_time 4 -hls_playlist_type vod -hls_segment_filename '720p_%03d.ts' 720p.m3u8 ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Live streaming, webinars and events. - Publishable-quality automatic captions and chapters. - Deep engagement analytics from an instrumented player. - Digital rights management. - live streaming, webinars, and events - auto captions, chapters, and the accessibility tooling around them - viewer analytics and engagement graphs - OTT, pay-per-view, and the monetization stack - a player QA'd against every browser, smart TV, and embed surface - traffic spikes absorbed by someone else's bandwidth bill If one of those is essential to you, that is the reason to keep paying for Vimeo, and the README should say so rather than pretend.
# Build brief · Vimeo The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a video host you own: an adaptive streaming ladder on object storage behind a content delivery network, with your own embed pages to replace Vimeo. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Transcoding: ffmpeg producing an HTTP Live Streaming ladder · adaptive streaming is what stops a video buffering on a phone, and ffmpeg does it without a service - Storage: S3-compatible object storage · segments are small immutable files, which is exactly what object storage is good at - Delivery: A content delivery network in front of the bucket · video is the one workload where serving from one machine is genuinely inadequate, and egress from a bucket without a network in front is usually the most expensive way to do it - Player: hls.js with the native player as a fallback · one player covers every browser, using native playback where it exists - Pages: A small server generating embed and watch pages · unlisted URLs, an accessible player page, and the metadata that makes a link preview correctly ### Data model (create this before Phase 1) - videos(id, title, description, source_key, duration_ms, width, height, state, created_at) - renditions(video_id, height, bitrate_kbps, playlist_key, segment_prefix, bytes) - jobs(id, video_id, state, started_at, finished_at, error) · transcoding is slow and fails, so it needs a state machine - captions(video_id, language, vtt_key, kind) · kind is subtitles or captions, and the distinction matters for accessibility - views(video_id, day, plays, watch_seconds) · derived from delivery logs rather than from a tracking script - Segments are immutable and content-addressed by rendition and index, so a cache never needs invalidating. ### Phase 1 · A ladder that actually adapts Build: Transcode a source into three or four renditions at sensible resolution and bitrate pairs. Produce per-rendition playlists and a master playlist. Keep keyframes aligned across renditions so a player can switch without a visible glitch. Trap: If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. Done when: A master playlist lists every rendition with correct bandwidth and resolution attributes. Forcing a player to switch renditions mid-playback produces no visible glitch. Keyframe timestamps are verified identical across renditions using a probe rather than by eye. Do not build yet: No upload, no delivery, no pages. ### Phase 2 · Storage and delivery Build: Upload playlists and segments to object storage with correct content types and long cache headers on segments. Put a content delivery network in front, and block direct bucket access so all traffic is cached traffic. Trap: Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. Done when: A video plays from the delivery network hostname. A direct request to the bucket is refused. Segment responses show a cache hit on a second request. Segments carry a long cache lifetime and playlists a short one, verified in response headers. Do not build yet: No pages or captions yet. ### Phase 3 · The player and the embed page Build: A watch page and an embed page using hls.js, falling back to native playback where the browser supports it directly. Correct metadata so a shared link previews with a thumbnail. Keyboard accessible controls. Trap: Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. Done when: The watch page plays in a Chromium browser, in Firefox and in Safari, using native playback in Safari. The embed page works inside an iframe on a different origin. A shared link previews with a thumbnail and title. Every control is reachable and operable by keyboard. Do not build yet: No captions or access control yet. ### Phase 4 · Captions, which are not optional Build: Accept caption files, or generate a draft transcript locally with whisper.cpp and require a human pass before publishing. Serve captions as a separate track the player exposes. Distinguish subtitles from captions, because they are different things. Trap: Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. Done when: A caption track displays in the player and can be turned off. A generated transcript is marked as a draft and cannot be published without an explicit review action. Caption timing stays aligned at the end of a long video. Multiple language tracks are selectable. Do not build yet: No access control or analytics yet. ### Phase 5 · Unlisted, private and expiring links Build: Unlisted videos with unguessable identifiers. Signed playback URLs with a short expiry for genuinely private videos. Domain restriction for embeds so a link lifted from your page does not play on someone else's site. Trap: An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate. Done when: An unlisted video is not discoverable through any listing. A private video's segments return a refusal without a valid signature, tested by requesting a segment URL directly. An embed restricted to a domain refuses to play when the page is copied to another origin. A signature expires and playback stops at a segment boundary. Do not build yet: Analytics and operations come last. ### Phase 6 · Costs, analytics and operations Build: Derive play counts and watch time from delivery logs rather than a tracking script. Track egress and compute cost per thousand views. Set a spend alert, because video is the workload where a popular link becomes an expensive week. Trap: A single video going unexpectedly wide can produce a bandwidth bill an order of magnitude above your normal month, and you find out afterwards. Set a spend alert at your provider and a hard limit if one is available, before publishing anything you expect to travel. Done when: Play counts and watch time are derived from delivery logs and match a manual count for a test video. Cost per thousand views is calculated from real egress figures. A spend alert is configured and has been tested by lowering the threshold. Transcoding failures are visible in the job state rather than silent. Do not build yet: Nothing after this. ### Out of scope (and why) - Live streaming, webinars and events, which are a different pipeline with different failure modes and no room to retry. - Automatic captions and chapters at publishable quality, which is why the caption phase requires a human pass. - The engagement analytics of the hosted product, which come from a player instrumented far more deeply than log analysis allows. - Content protection beyond signed URLs. Digital rights management is a licensing relationship, not a feature you add. ### README must contain - The ladder rungs and why keyframes must align across renditions - That the bucket is not directly reachable and everything is served through the delivery network - That Safari plays natively and the JavaScript player is not attached there - That generated transcripts are drafts and cannot be published without review - The measured cost per thousand views, and that a spend alert is configured
# Agent instructions · Vimeo indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: ffmpeg producing an HTTP Live Streaming ladder, S3-compatible object storage, A content delivery network in front of the bucket, hls.js with the native player as a fallback, A small server generating embed and watch pages. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. - Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. - Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. - Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. - An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate.
# Build plan · Vimeo A pipeline that transcodes a source video into an adaptive bitrate ladder with ffmpeg, uploads the segments to object storage behind a content delivery network, and serves unlisted embed pages with your own player, so playback adapts to the viewer's connection and the bandwidth bill is one you can read. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A ladder that actually adapts One source produces several renditions and a master playlist a player can switch between. ### Steps 1. Choose the ladder deliberately Three or four rungs covering a phone on mobile data through to a desktop. More rungs cost transcoding time and storage for diminishing benefit. Files: `docs/ladder.md` 2. Force aligned keyframes A fixed group of pictures size with scene change detection off, applied identically to every rendition. ```sh ffmpeg -i in.mp4 -g 48 -keyint_min 48 -sc_threshold 0 -c:v libx264 -b:v 2500k -maxrate 2675k -bufsize 3750k -hls_time 4 -hls_playlist_type vod -hls_segment_filename '720p_%03d.ts' 720p.m3u8 ``` 3. Verify alignment with a probe Extract keyframe timestamps per rendition and assert they match. Do not judge this by watching. Files: `scripts/verify_keyframes.sh` ```sh ffprobe -select_streams v -skip_frame nokey -show_entries frame=pkt_pts_time -of csv 720p.m3u8 ``` ### Done when - [ ] The master playlist lists every rendition with correct attributes - [ ] Keyframe timestamps match across renditions, verified by probe - [ ] A forced rendition switch produces no visible glitch - [ ] Each rendition plays standalone ### Watch out - If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. ## Phase 2 · Storage and delivery Segments are served from a cache close to the viewer, and the bucket is not directly reachable. ### Steps 1. Upload with correct headers Segments get a long cache lifetime because they never change. Playlists get a short one because they can. Files: `src/upload.py` 2. Put a delivery network in front Origin is the bucket, and the bucket only accepts requests from the network. Test the direct path is closed. 3. Verify caching behaviour Request a segment twice and check for a cache hit. A network that misses every time is costing you origin egress anyway. ```sh curl -sI https://cdn.example.com/v/abc/720p_001.ts | grep -i cache ``` ### Done when - [ ] Playback works through the delivery network hostname - [ ] Direct bucket requests are refused - [ ] A repeated segment request shows a cache hit - [ ] Segments and playlists have different cache lifetimes ### Watch out - Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. ## Phase 3 · The player and the embed page A link plays anywhere, including on a phone, and can be embedded on another site. ### Steps 1. Detect native support first If the browser can play the stream natively, let it, and do not attach the JavaScript player at all. Files: `static/player.js` 2. Build watch and embed pages Embed is minimal and frameable; watch has the title, description and a share control. Files: `templates/` 3. Add link preview metadata and keyboard controls Open Graph tags with a generated thumbnail, and controls that work without a mouse. Files: `templates/watch.html` ### Done when - [ ] Playback works in three browsers, natively in Safari - [ ] The embed page works in an iframe on another origin - [ ] A shared link previews with a thumbnail - [ ] All controls are keyboard operable ### Watch out - Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. ## Phase 4 · Captions, which are not optional Every video can carry captions, and they display correctly. ### Steps 1. Support caption uploads Standard timed text files, validated on upload, served as separate tracks. Files: `src/captions.py` 2. Generate drafts locally whisper.cpp for a first pass, stored as a draft with a clear state. Files: `src/transcribe.py` 3. Require review before publishing A draft cannot become a published track without a deliberate action. Enforce it in the state machine, not in the interface only. Files: `src/captions.py` ### Done when - [ ] A caption track displays and can be disabled - [ ] A generated draft cannot be published without review - [ ] Timing stays aligned at the end of a long video - [ ] Multiple languages are selectable ### Watch out - Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. ## Phase 5 · Unlisted, private and expiring links A video can be shared with some people and not with everyone. ### Steps 1. Use unguessable identifiers Enough entropy that enumeration is impractical, and no sequential identifiers anywhere in URLs. Files: `src/ids.py` 2. Sign segment requests for private videos The delivery network validates the signature. Test by requesting a segment directly without one. 3. Restrict embeds by referrer or signature Referrer restriction is weak but useful; signed embeds are stronger. Say which you chose and what it does not stop. Files: `src/embed.py` ### Done when - [ ] An unlisted video appears in no listing - [ ] A private video's segment is refused without a signature - [ ] A restricted embed refuses on another origin - [ ] An expired signature stops playback at a segment boundary ### Watch out - An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate. ## Not in this build - Live streaming, webinars and events. - Publishable-quality automatic captions and chapters. - Deep engagement analytics from an instrumented player. - Digital rights management. ## After v1, if you want it - Per-title encoding, choosing bitrates from the content rather than a fixed ladder - Thumbnail sprites for scrub previews, which are cheap and much missed when absent - A second delivery network as a failover origin, once the first one has an outage
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where playlists and segments are stored. BUCKET_NAME=video-segments # Required · secret. Scoped to this bucket only. BUCKET_ACCESS_KEY=replace-with-key # Required · secret. Kept out of the repository. BUCKET_SECRET_KEY=replace-with-secret # Required. The hostname viewers actually fetch from. The bucket hostname must never appear in a playlist. CDN_BASE_URL=https://cdn.example.com # Optional · secret. Signs playback URLs for private videos. Required only if you use private mode. SIGNING_KEY=replace-with-a-long-random-value # Optional. How long a signed playback URL lasts. Long enough for the video, short enough to matter. SIGNED_URL_TTL_SECONDS=3600 # Required. Height and bitrate pairs. Each rung costs transcoding time and storage. LADDER=360:800,720:2500,1080:5000
# Vimeo · product brief ## Problem Hosting your own video stopped being exotic: ffmpeg builds the HLS ladder, object storage holds it, a CDN serves it, and hls.js plays it. For a portfolio or a client reel that is a weekend, and the result has no ads, no upsells, and no platform that can change its mind about your account. What you are actually buying from Vimeo is the delivery layer holding up when 4,000 people open the link at once, plus live streaming, captions, analytics, and a player that has been QA'd against every browser, smart TV, and embed context you will never test. Small library, predictable traffic · build it. Anything with a spike in it · the bandwidth bill is the whole argument. ## Product outcome A video host where playback adapts cleanly across renditions, delivery is cached rather than paid for at bucket egress rates, captions are never published unreviewed, private really means private, and the cost per thousand views is a number you know. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - ffmpeg - object storage (R2, B2, or S3) - a CDN in front of it - a small VPS for the pages - an egress budget you now own ## Explicit non-goals for v1 - Live streaming, webinars and events. - Publishable-quality automatic captions and chapters. - Deep engagement analytics from an instrumented player. - Digital rights management. - live streaming, webinars, and events - auto captions, chapters, and the accessibility tooling around them - viewer analytics and engagement graphs - OTT, pay-per-view, and the monetization stack - a player QA'd against every browser, smart TV, and embed surface - traffic spikes absorbed by someone else's bandwidth bill ## Success criteria - Keyframe alignment is verified by probe across all renditions - Direct bucket access is confirmed refused - Playback is verified in three browsers including native Safari - A private video's segment is confirmed refused without a signature - A spend alert has been tested by triggering it
# Build brief · Vimeo The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a video host you own: an adaptive streaming ladder on object storage behind a content delivery network, with your own embed pages to replace Vimeo. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Transcoding: ffmpeg producing an HTTP Live Streaming ladder · adaptive streaming is what stops a video buffering on a phone, and ffmpeg does it without a service - Storage: S3-compatible object storage · segments are small immutable files, which is exactly what object storage is good at - Delivery: A content delivery network in front of the bucket · video is the one workload where serving from one machine is genuinely inadequate, and egress from a bucket without a network in front is usually the most expensive way to do it - Player: hls.js with the native player as a fallback · one player covers every browser, using native playback where it exists - Pages: A small server generating embed and watch pages · unlisted URLs, an accessible player page, and the metadata that makes a link preview correctly ### Data model (create this before Phase 1) - videos(id, title, description, source_key, duration_ms, width, height, state, created_at) - renditions(video_id, height, bitrate_kbps, playlist_key, segment_prefix, bytes) - jobs(id, video_id, state, started_at, finished_at, error) · transcoding is slow and fails, so it needs a state machine - captions(video_id, language, vtt_key, kind) · kind is subtitles or captions, and the distinction matters for accessibility - views(video_id, day, plays, watch_seconds) · derived from delivery logs rather than from a tracking script - Segments are immutable and content-addressed by rendition and index, so a cache never needs invalidating. ### Phase 1 · A ladder that actually adapts Build: Transcode a source into three or four renditions at sensible resolution and bitrate pairs. Produce per-rendition playlists and a master playlist. Keep keyframes aligned across renditions so a player can switch without a visible glitch. Trap: If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. Done when: A master playlist lists every rendition with correct bandwidth and resolution attributes. Forcing a player to switch renditions mid-playback produces no visible glitch. Keyframe timestamps are verified identical across renditions using a probe rather than by eye. Do not build yet: No upload, no delivery, no pages. ### Phase 2 · Storage and delivery Build: Upload playlists and segments to object storage with correct content types and long cache headers on segments. Put a content delivery network in front, and block direct bucket access so all traffic is cached traffic. Trap: Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. Done when: A video plays from the delivery network hostname. A direct request to the bucket is refused. Segment responses show a cache hit on a second request. Segments carry a long cache lifetime and playlists a short one, verified in response headers. Do not build yet: No pages or captions yet. ### Phase 3 · The player and the embed page Build: A watch page and an embed page using hls.js, falling back to native playback where the browser supports it directly. Correct metadata so a shared link previews with a thumbnail. Keyboard accessible controls. Trap: Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. Done when: The watch page plays in a Chromium browser, in Firefox and in Safari, using native playback in Safari. The embed page works inside an iframe on a different origin. A shared link previews with a thumbnail and title. Every control is reachable and operable by keyboard. Do not build yet: No captions or access control yet. ### Phase 4 · Captions, which are not optional Build: Accept caption files, or generate a draft transcript locally with whisper.cpp and require a human pass before publishing. Serve captions as a separate track the player exposes. Distinguish subtitles from captions, because they are different things. Trap: Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. Done when: A caption track displays in the player and can be turned off. A generated transcript is marked as a draft and cannot be published without an explicit review action. Caption timing stays aligned at the end of a long video. Multiple language tracks are selectable. Do not build yet: No access control or analytics yet. ### Phase 5 · Unlisted, private and expiring links Build: Unlisted videos with unguessable identifiers. Signed playback URLs with a short expiry for genuinely private videos. Domain restriction for embeds so a link lifted from your page does not play on someone else's site. Trap: An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate. Done when: An unlisted video is not discoverable through any listing. A private video's segments return a refusal without a valid signature, tested by requesting a segment URL directly. An embed restricted to a domain refuses to play when the page is copied to another origin. A signature expires and playback stops at a segment boundary. Do not build yet: Analytics and operations come last. ### Phase 6 · Costs, analytics and operations Build: Derive play counts and watch time from delivery logs rather than a tracking script. Track egress and compute cost per thousand views. Set a spend alert, because video is the workload where a popular link becomes an expensive week. Trap: A single video going unexpectedly wide can produce a bandwidth bill an order of magnitude above your normal month, and you find out afterwards. Set a spend alert at your provider and a hard limit if one is available, before publishing anything you expect to travel. Done when: Play counts and watch time are derived from delivery logs and match a manual count for a test video. Cost per thousand views is calculated from real egress figures. A spend alert is configured and has been tested by lowering the threshold. Transcoding failures are visible in the job state rather than silent. Do not build yet: Nothing after this. ### Out of scope (and why) - Live streaming, webinars and events, which are a different pipeline with different failure modes and no room to retry. - Automatic captions and chapters at publishable quality, which is why the caption phase requires a human pass. - The engagement analytics of the hosted product, which come from a player instrumented far more deeply than log analysis allows. - Content protection beyond signed URLs. Digital rights management is a licensing relationship, not a feature you add. ### README must contain - The ladder rungs and why keyframes must align across renditions - That the bucket is not directly reachable and everything is served through the delivery network - That Safari plays natively and the JavaScript player is not attached there - That generated transcripts are drafts and cannot be published without review - The measured cost per thousand views, and that a spend alert is configured
# Architecture · Vimeo ## Stack | Part | Choice | Why | | --- | --- | --- | | Transcoding | ffmpeg producing an HTTP Live Streaming ladder | adaptive streaming is what stops a video buffering on a phone, and ffmpeg does it without a service | | Storage | S3-compatible object storage | segments are small immutable files, which is exactly what object storage is good at | | Delivery | A content delivery network in front of the bucket | video is the one workload where serving from one machine is genuinely inadequate, and egress from a bucket without a network in front is usually the most expensive way to do it | | Player | hls.js with the native player as a fallback | one player covers every browser, using native playback where it exists | | Pages | A small server generating embed and watch pages | unlisted URLs, an accessible player page, and the metadata that makes a link preview correctly | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Transcode | Producing the ladder with aligned keyframes. | ffmpeg is the tool. The keyframe alignment requirement survives any change of encoder or settings. | | Storage and delivery | Serving segments cheaply and close to viewers. | Providers are interchangeable; the rule that the bucket is never directly reachable is not. | | Player | Playback across browsers. | Any player library. Native playback where it exists must stay, because fighting it produces the worst bugs in this build. | | Captions | Tracks and the review gate. | Transcription engines vary; the requirement that a draft cannot be published unreviewed is an accessibility commitment rather than a workflow preference. | | Access | Unlisted identifiers and signed segments. | The distinction between obscurity and access control must remain visible in whatever replaces this. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `BUCKET_NAME` · required · Where playlists and segments are stored. - `BUCKET_ACCESS_KEY` · required, secret · Scoped to this bucket only. - `BUCKET_SECRET_KEY` · required, secret · Kept out of the repository. - `CDN_BASE_URL` · required · The hostname viewers actually fetch from. The bucket hostname must never appear in a playlist. - `SIGNING_KEY` · optional, secret · Signs playback URLs for private videos. Required only if you use private mode. - `SIGNED_URL_TTL_SECONDS` · optional · How long a signed playback URL lasts. Long enough for the video, short enough to matter. - `LADDER` · required · Height and bitrate pairs. Each rung costs transcoding time and storage. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Vimeo product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: ffmpeg producing an HTTP Live Streaming ladder, S3-compatible object storage, A content delivery network in front of the bucket, hls.js with the native player as a fallback, A small server generating embed and watch pages. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. - Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. - Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. - Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. - An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate. - A single video going unexpectedly wide can produce a bandwidth bill an order of magnitude above your normal month, and you find out afterwards. Set a spend alert at your provider and a hard limit if one is available, before publishing anything you expect to travel.
# Delivery milestones · Vimeo Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A ladder that actually adapts One source produces several renditions and a master playlist a player can switch between. ### Steps 1. Choose the ladder deliberately Three or four rungs covering a phone on mobile data through to a desktop. More rungs cost transcoding time and storage for diminishing benefit. Files: `docs/ladder.md` 2. Force aligned keyframes A fixed group of pictures size with scene change detection off, applied identically to every rendition. ```sh ffmpeg -i in.mp4 -g 48 -keyint_min 48 -sc_threshold 0 -c:v libx264 -b:v 2500k -maxrate 2675k -bufsize 3750k -hls_time 4 -hls_playlist_type vod -hls_segment_filename '720p_%03d.ts' 720p.m3u8 ``` 3. Verify alignment with a probe Extract keyframe timestamps per rendition and assert they match. Do not judge this by watching. Files: `scripts/verify_keyframes.sh` ```sh ffprobe -select_streams v -skip_frame nokey -show_entries frame=pkt_pts_time -of csv 720p.m3u8 ``` ### Done when - [ ] The master playlist lists every rendition with correct attributes - [ ] Keyframe timestamps match across renditions, verified by probe - [ ] A forced rendition switch produces no visible glitch - [ ] Each rendition plays standalone ### Watch out - If keyframes are not at identical timestamps in every rendition, the player cannot switch cleanly and viewers see a stutter or a black frame at each change. Force a fixed keyframe interval and disable scene-change keyframes, so every rendition has a keyframe at exactly the same points. ## M2 · Storage and delivery Segments are served from a cache close to the viewer, and the bucket is not directly reachable. ### Steps 1. Upload with correct headers Segments get a long cache lifetime because they never change. Playlists get a short one because they can. Files: `src/upload.py` 2. Put a delivery network in front Origin is the bucket, and the bucket only accepts requests from the network. Test the direct path is closed. 3. Verify caching behaviour Request a segment twice and check for a cache hit. A network that misses every time is costing you origin egress anyway. ```sh curl -sI https://cdn.example.com/v/abc/720p_001.ts | grep -i cache ``` ### Done when - [ ] Playback works through the delivery network hostname - [ ] Direct bucket requests are refused - [ ] A repeated segment request shows a cache hit - [ ] Segments and playlists have different cache lifetimes ### Watch out - Serving video directly from a bucket means paying that provider's egress rate for every byte of every view, which is usually several times what a delivery network in front costs and scales badly the moment anything is popular. Put the network in front before you share a single link, not after the first bill. ## M3 · The player and the embed page A link plays anywhere, including on a phone, and can be embedded on another site. ### Steps 1. Detect native support first If the browser can play the stream natively, let it, and do not attach the JavaScript player at all. Files: `static/player.js` 2. Build watch and embed pages Embed is minimal and frameable; watch has the title, description and a share control. Files: `templates/` 3. Add link preview metadata and keyboard controls Open Graph tags with a generated thumbnail, and controls that work without a mouse. Files: `templates/watch.html` ### Done when - [ ] Playback works in three browsers, natively in Safari - [ ] The embed page works in an iframe on another origin - [ ] A shared link previews with a thumbnail - [ ] All controls are keyboard operable ### Watch out - Safari plays HTTP Live Streaming natively and will fight a JavaScript player attached to the same element, producing double playback or none. Detect native support and use it, loading the JavaScript player only where it is needed. ## M4 · Captions, which are not optional Every video can carry captions, and they display correctly. ### Steps 1. Support caption uploads Standard timed text files, validated on upload, served as separate tracks. Files: `src/captions.py` 2. Generate drafts locally whisper.cpp for a first pass, stored as a draft with a clear state. Files: `src/transcribe.py` 3. Require review before publishing A draft cannot become a published track without a deliberate action. Enforce it in the state machine, not in the interface only. Files: `src/captions.py` ### Done when - [ ] A caption track displays and can be disabled - [ ] A generated draft cannot be published without review - [ ] Timing stays aligned at the end of a long video - [ ] Multiple languages are selectable ### Watch out - Automatic transcription is a draft, not captions. Publishing an unreviewed machine transcript as captions produces confidently wrong text for exactly the viewers who depend on it most, and it is worse than none because it looks authoritative. Require the review step in the workflow rather than trusting people to remember. ## M5 · Unlisted, private and expiring links A video can be shared with some people and not with everyone. ### Steps 1. Use unguessable identifiers Enough entropy that enumeration is impractical, and no sequential identifiers anywhere in URLs. Files: `src/ids.py` 2. Sign segment requests for private videos The delivery network validates the signature. Test by requesting a segment directly without one. 3. Restrict embeds by referrer or signature Referrer restriction is weak but useful; signed embeds are stronger. Say which you chose and what it does not stop. Files: `src/embed.py` ### Done when - [ ] An unlisted video appears in no listing - [ ] A private video's segment is refused without a signature - [ ] A restricted embed refuses on another origin - [ ] An expired signature stops playback at a segment boundary ### Watch out - An unguessable URL is obscurity rather than access control, which is fine for unlisted and useless for private. If a video is genuinely private, the segments must require signed requests, because otherwise anyone with the playlist can fetch every segment directly and the player was never the gate. ## M6 · Costs, analytics and operations (production only) You know what a view costs and what it would cost if a video did well. ### Steps 1. Derive analytics from logs Segment requests grouped by video give plays and watch time without a tracking script and without consent complications. Files: `src/analytics.py` 2. Calculate real cost per thousand views Egress bytes times your rate, plus storage and transcoding. Publish it in your own documentation so the number is not a surprise. Files: `docs/costs.md` 3. Set and test a spend alert Lower the threshold deliberately and confirm the alert arrives. An untested alert is a hope. ### Done when - [ ] Log-derived play counts match a manual count - [ ] Cost per thousand views is calculated from real figures - [ ] A spend alert has been triggered deliberately and arrived - [ ] A failed transcode is visible in the job state ### Watch out - A single video going unexpectedly wide can produce a bandwidth bill an order of magnitude above your normal month, and you find out afterwards. Set a spend alert at your provider and a hard limit if one is available, before publishing anything you expect to travel.
# Operations · Vimeo ## Backup Source videos are irreplaceable and belong in a backup. Renditions are regenerable from sources, so backing them up is optional and usually not worth the storage, provided the transcode settings are version controlled. ## Restore Restore sources and settings, re-transcode, re-upload. Time this once for one video so you know what a full rebuild would cost in hours. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Egress volume against a budget, cache hit ratio, and transcode job failures. A falling cache hit ratio means you are paying origin egress for traffic you thought was cached. ## Incident checklist A video going wide is the incident, and the spend alert is the control. If it fires, the levers are removing the top rung of the ladder, tightening cache lifetimes and, if necessary, taking the video private until you decide. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Keyframe alignment is verified by probe across all renditions - [ ] Direct bucket access is confirmed refused - [ ] Playback is verified in three browsers including native Safari - [ ] A private video's segment is confirmed refused without a signature - [ ] A spend alert has been tested by triggering it ## Launch constraint Do not market omitted Vimeo capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where playlists and segments are stored. BUCKET_NAME=video-segments # Required · secret. Scoped to this bucket only. BUCKET_ACCESS_KEY=replace-with-key # Required · secret. Kept out of the repository. BUCKET_SECRET_KEY=replace-with-secret # Required. The hostname viewers actually fetch from. The bucket hostname must never appear in a playlist. CDN_BASE_URL=https://cdn.example.com # Optional · secret. Signs playback URLs for private videos. Required only if you use private mode. SIGNING_KEY=replace-with-a-long-random-value # Optional. How long a signed playback URL lasts. Long enough for the video, short enough to matter. SIGNED_URL_TTL_SECONDS=3600 # Required. Height and bitrate pairs. Each rung costs transcoding time and storage. LADDER=360:800,720:2500,1080:5000
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay so the embed on the homepage never buffers on launch day. Video is the one file type where the difference between a good and a bad host is visible to every single viewer within two seconds, and the failure mode is silent: people just leave. Vimeo also stays cheaper than self-hosting the moment traffic is uneven, because you are buying a smoothed average of someone else's egress instead of your own worst month.
xlive streaming, webinars, and events
xauto captions, chapters, and the accessibility tooling around them
xviewer analytics and engagement graphs
xOTT, pay-per-view, and the monetization stack
xa player QA'd against every browser, smart TV, and embed surface
xtraffic spikes absorbed by someone else's bandwidth bill
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Vimeo pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 1 seat; 1 GB total lifetime storage; 2 TB bandwidth/month; 30-minute screen recordings; up to 3 showcases, 1 channel and 1 group |
| starter | $20/user | $12/user | 1 included user; 2 TB storage; 2 TB bandwidth/month; unlimited screen recordings |
| standard | $41/user | $25/user | 5 users included; 4 TB storage; 2 TB bandwidth/month; branding, lead capture and calls to action |
| advanced | $125/user | $75/user | 10 users included; 7 TB storage; 2 TB bandwidth/month; 2 concurrent streams; 7 guest speakers; 100 registrants/event |
| enterprise | custom | — | Custom users, storage, bandwidth, AI, events, security and support |
free tier1 seat, 1 GB total lifetime storage, 2 TB bandwidth/month, 30-minute screen recordings, 3 showcases, 1 channel and 1 group
billingmonthly + annual; annual billed yearly; auto-renews; refund window is 14 days for annual and 3 days for monthly purchases where eligible
hidden costsAdditional Admin/Contributor seats are paid; AI translation/dubbing uses purchased credits, and accounts approaching 2 TB monthly bandwidth may need to reduce usage or negotiate a larger plan.
verified 2026-08-14 · source ↗
Vibecode Vimeo
Kinda. The core of Vimeo is buildable in a weekend with the prompt on this page, but there are real gaps: live streaming, webinars, and events, auto captions, chapters, and the accessibility tooling around them. Read the honest list above before committing.
How much does Vimeo cost?
Vimeo costs about $41/month (Standard, checked 2026-07-30), which is $492 per year.
What do I lose by replacing Vimeo?
Honestly: live streaming, webinars, and events; auto captions, chapters, and the accessibility tooling around them; viewer analytics and engagement graphs; OTT, pay-per-view, and the monetization stack; a player QA'd against every browser, smart TV, and embed surface; traffic spikes absorbed by someone else's bandwidth bill. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Vimeo?
Yes: PeerTube (Vimeo on your server, complete with embeds and privacy controls; bandwidth is now your problem.) MediaCMS (A private YouTube-shaped cabinet with permissions, embeds and local transcription.) The prompt is for when you want it exactly your way.