Build Vimeo

KINDAreplaces $41/mosaves $492/yrback to the verdict

0%0 of 20 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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 weekendthe files for this build are in the project pack

Transcodingffmpeg producing an HTTP Live Streaming ladderStorageS3-compatible object storageDeliveryA content delivery network in front of the bucketPlayerhls.js with the native player as a fallbackPagesA small server generating embed and watch pages

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    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. open ↗

    Verify ffmpeg -encoders lists libx264 and ffmpeg -formats lists hls

  2. API keystorage 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

  3. 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

  4. have readyfree

    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

  5. installfree, 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. open ↗

    Verify It transcribes a 16 kHz mono WAV

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- 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.

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
BUCKET_NAMErequiredvideo-segmentsWhere playlists and segments are stored.
BUCKET_ACCESS_KEYsecretrequiredreplace-with-keyScoped to this bucket only.
BUCKET_SECRET_KEYsecretrequiredreplace-with-secretKept out of the repository.
CDN_BASE_URLrequiredhttps://cdn.example.comThe hostname viewers actually fetch from. The bucket hostname must never appear in a playlist.
SIGNING_KEYsecretoptionalreplace-with-a-long-random-valueSigns playback URLs for private videos. Required only if you use private mode.
SIGNED_URL_TTL_SECONDSoptional3600How long a signed playback URL lasts. Long enough for the video, short enough to matter.
LADDERrequired360:800,720:2500,1080:5000Height and bitrate pairs. Each rung costs transcoding time and storage.

The build, in order

  1. A ladder that actually adapts

    One source produces several renditions and a master playlist a player can switch between.

    1. 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. A fixed group of pictures size with scene change detection off, applied identically to every rendition.

      terminal
      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. Extract keyframe timestamps per rendition and assert they match. Do not judge this by watching.

      Files scripts/verify_keyframes.sh

      terminal
      ffprobe -select_streams v -skip_frame nokey -show_entries frame=pkt_pts_time -of csv 720p.m3u8
    done when · tick each as it passes
    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.
  2. Storage and delivery

    Segments are served from a cache close to the viewer, and the bucket is not directly reachable.

    1. Segments get a long cache lifetime because they never change. Playlists get a short one because they can.

      Files src/upload.py

    2. Origin is the bucket, and the bucket only accepts requests from the network. Test the direct path is closed.

    3. Request a segment twice and check for a cache hit. A network that misses every time is costing you origin egress anyway.

      terminal
      curl -sI https://cdn.example.com/v/abc/720p_001.ts | grep -i cache
    done when · tick each as it passes
    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.
  3. The player and the embed page

    A link plays anywhere, including on a phone, and can be embedded on another site.

    1. If the browser can play the stream natively, let it, and do not attach the JavaScript player at all.

      Files static/player.js

    2. Embed is minimal and frameable; watch has the title, description and a share control.

      Files templates/

    3. Open Graph tags with a generated thumbnail, and controls that work without a mouse.

      Files templates/watch.html

    done when · tick each as it passes
    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.
  4. Captions, which are not optional

    Every video can carry captions, and they display correctly.

    1. Standard timed text files, validated on upload, served as separate tracks.

      Files src/captions.py

    2. whisper.cpp for a first pass, stored as a draft with a clear state.

      Files src/transcribe.py

    3. 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 · tick each as it passes
    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.
  5. Unlisted, private and expiring links

    A video can be shared with some people and not with everyone.

    1. Enough entropy that enumeration is impractical, and no sequential identifiers anywhere in URLs.

      Files src/ids.py

    2. The delivery network validates the signature. Test by requesting a segment directly without one.

    3. 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 · tick each as it passes
    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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more audio & video.