yapern

Create a message

B I S x2 H “” </> •••

0/2000

Create new sub-yap

← Blog

Hosted Videos with CloudFlare Images & Streams

· Yapern

Yapern already let people attach a screenshot to a post. Attaching a phone recording looks like the same feature with a different file picker, until someone has to play it. Storing the original file is easy. Getting it into a format that plays well on somebody else’s phone, over somebody else’s connection, is more work.

We chose CloudFlare Stream for video and kept our images in R2. That keeps our forum entirely on CloudFlare, including encoding and delivery, without making a video pipeline our next project. To be precise about the product names, we aren’t using the hosted CloudFlare Images service: our image uploads go through a Worker into R2, and the video service is called Stream. The interesting part of adding it was deciding which work to hand over and which promises the application still has to keep.

Five ways to host an attachment

We considered keeping video in R2 too. We also looked at Mux and Bunny, because "we already use CloudFlare" would be a lousy excuse to ignore a service that suits the job better. These were the choices for the first version:

OptionWhat it gives usOur assessment
CloudFlare StreamDirect uploads, encoding, adaptive playback up to 1080p, thumbnails, and a hosted player.The managed service that fits our existing CloudFlare stack.
MP4 files in R2Storage and delivery of the files we put there.Reasonable for short clips if we control their encoding. Arbitrary phone uploads leave us with more compatibility work.
Our own HLS in R2A place for playlists and video segments.We would own encoding, packaging, jobs, retries, and cleanup. Too much infrastructure for this version.
MuxManaged video, player tooling, playback analytics, and automatic cold storage discounts.A serious alternative, especially as old attachments accumulate.
Bunny StreamManaged video with free standard encoding and storage and bandwidth billing.Worth comparing when bandwidth cost becomes a bigger part of the bill.

An ordinary MP4 isn’t automatically a bad answer. A short H.264/AAC clip can play progressively and seek when it has fast-start metadata and the server supports byte-range requests. If we controlled the recordings, that could be enough. A forum’s upload button gives us much less control over the files arriving.

HLS handles a different part of that problem. An .m3u8 playlist points to video segments; an adaptive stream offers several encoded quality levels so the player can switch as the connection changes. Splitting the original recording into chunks doesn’t create those other versions. Building that ourselves means operating the encoders and jobs behind the playlists, as well as hosting their output.

There’s also a smaller CloudFlare option: R2 with Media Transformations can produce optimized MP4s and still frames. Its documented limits include 60-second video outputs and inputs under 100 MB, with H.264 MP4 as the recommended source format. That’s useful for controlled clips or previews. We wanted uploads up to two minutes without requiring people to prepare the right MP4 first.

Stream wins on fit, not every price

The cost we’ll keep paying is storage. A video in an old thread can go unwatched for months and still occupy space. These are published starting rates checked on October 22, 2026, not measurements of Yapern’s usage:

Managed video pricing uses different units
ServiceStorage per monthDelivery
Stream$5 per 1,000 minutes of capacity, bought in increments.$1 per 1,000 minutes delivered. Encoding is included.
Mux Basic$2.40 per 1,000 minutes at 720p; $3 at 1080p, before cold storage discounts.First 100,000 delivery minutes free each month.
Bunny StreamFrom $0.01 per GB in one region.Volume network starts at $0.005 per GB; Standard rates vary by region.

For a hypothetical library of 10,000 one-minute videos, all retained for a month, with 10,000 minutes delivered that month, Stream comes to $50 for storage capacity plus $10 for delivery: $60 a month. That’s the video service alone. It excludes the rest of the application and assumes no extra capacity needed for pending uploads.

For comparison, R2 Standard storage is $0.015 per GB-month with free Internet egress. At an assumed 20 MB per original, those same 10,000 clips occupy about 200 GB, or roughly $3 a month in base storage before the free allowance. That buys storage for the originals. Encoding compute, extra renditions, requests, and Worker usage still need accounting for.

Mux’s lower base storage rate, free delivery allowance, and discounts for unwatched assets deserve attention. Basic encoding is free, though Basic assets have a one-month minimum storage charge. Bunny’s bill depends on encoded file sizes, replication, and the chosen delivery network, so minutes alone won’t produce a fair comparison.

We chose Stream because the site, API, database, and images already run on CloudFlare, and Stream adds the missing encoding and playback service there. Keeping that work in the same platform is worth something to us while we’re building the forum. It isn’t evidence that Stream is the cheapest provider. If retention or viewing costs start to dominate, we have a concrete reason to revisit Mux and Bunny.

The video bytes skip our Worker

Our existing image route accepts one image per message. The Worker checks its size, MIME type, and file signature, writes it to R2, and saves its URL with the post. PNG, JPEG, GIF, and WebP uploads are limited to 5 MiB. The image is then available through a public API route.

Video uses a separate reservation. The browser sends POST /videos with the intended board and file size. After checking the session and limits, the API records an upload in D1 and asks Stream for an expiring upload URL. The browser sends the file straight to that URL. Its Yapern bearer token goes to our API, never to the file upload destination.

Two upload paths. Image bytes pass from the browser through the Worker to R2. For video, the browser and Worker exchange a reservation, the Worker provisions an upload in Stream and records its identity in D1, and the large video transfer goes directly from the browser to Stream.

The reservation code limits each video to 100 MiB and 120 seconds, allows twenty reservations per user per UTC day, and caps active, unattached uploads at three. Upload URLs expire after thirty minutes. Those limits matter before a file arrives: Stream temporarily reserves the upload’s maximum duration against storage capacity until the upload completes or expires.

The binding changed the upload protocol

The first frontend commit used tus, a resumable upload protocol. The REST path creates a tus upload URL, and the browser sends 5 MiB chunks with retries. Keeping the same URL lets it resume an interrupted upload while that draft and reservation remain usable; we don’t persist a resume fingerprint across page reloads.

The backend’s Stream binding change added a second path. With the binding configured, the Worker provisions the upload directly:

await env.STREAM.createDirectUpload({
  maxDurationSeconds: 120,
  expiry: new Date(expires).toISOString(),
  creator: session.user_id,
  meta: { name: id },
});

That call returns a basic POST upload URL. CloudFlare documents the difference: the binding currently provisions POST uploads; tus uses the REST API. Our API therefore returns upload_protocol, and the browser chooses multipart POST or tus accordingly. The configured binding takes the multipart path, which doesn’t resume an interrupted transfer. On failure, the composer discards that reservation and starts a fresh one on retry. The REST fallback retains tus support. Staying on one platform didn’t make the two protocols interchangeable.

100% uploaded still isn’t ready

The next frontend fix makes a small but useful promise: upload progress stops at 99% until Stream accepts the request. Sending the last byte isn’t enough to claim success. Even after acceptance, the video may need encoding, so the composer moves on to "Processing video..." and asks our completion endpoint for its state.

The client can’t mark its own video ready. The API reads Stream’s status and requires a valid playback origin, a positive duration, and dimensions before exposing a ready player. It checks reported size and duration against our limits too. D1 keeps the provider asset ID and processing state separately from the message text.

A schematic timeline separates bytes sent, upload acceptance, and playback readiness. Progress stays at 99% until acceptance, then shows processing until trusted provider metadata makes the player available. A post may be saved during processing when a watch URL is known.

A post can be saved while the video is processing, once Stream supplies a valid watch URL. That avoids making the entire message wait for the final rendition. The attachment operation checks the owner and board, then links one upload to one message in a D1 batch. Retrying the same attachment with the same title and body returns the existing message. Different content gets a conflict.

We also prepend Stream’s watch URL to the saved body for older clients. The current renderer uses the structured video object and strips that fallback link from the displayed caption, so the post doesn’t show the same attachment twice.

Processing updates can arrive through a Stream webhook. Our handler verifies HMAC-SHA256 over the timestamp and exact request body, compares signatures in constant time, and rejects timestamps more than five minutes away. Updates only change uploads still in uploading or processing. A repeated or delayed event can’t turn a ready video back into a pending one or revive a deletion.

Webhooks aren’t the only way forward. Status requests refresh pending videos, with a D1 timestamp limiting ordinary provider checks to once per fifteen seconds. A scheduled job runs every five minutes and reconciles a bounded batch as well. In the feed, pending videos show a processing message, make a limited run of automatic checks, and retain a "Check again" button.

Let people choose what to play

A ready attachment starts as a lazy-loaded poster with its duration. The renderer only creates Stream’s player iframe when someone presses Play. It uses the reported dimensions to preserve the aspect ratio and treats portrait recordings separately. Scrolling past a video doesn’t create a player.

That also affects the bill. Stream counts client preloading and buffering as delivered video, so filling a feed with eager players could charge us for clips nobody chose to watch. When playback starts, Stream serves the video. We don’t copy its HLS playlists or segments into R2; CloudFlare specifically advises reading dynamic manifests directly from Stream.

These attachments are public, like the messages and images they belong to. Unattached upload status requires the owner’s session, but a public playback URL isn’t a private attachment. Private messages would need viewer authorization and signed playback, including thumbnail access. We haven’t implemented that private-media flow.

Deleting the post has to delete the video

Saving a message and deleting an asset from Stream aren’t one transaction. If the provider is unavailable when somebody deletes a post, losing the asset ID would leave us paying for a video we can’t find through the message anymore.

The video migration keeps the upload row after message deletion. A trigger marks it deleting before the message disappears, and the foreign key clears its message reference. The API attempts the provider deletion immediately. If that fails, the row survives for the scheduled job to retry. Only after Stream confirms deletion, or says the asset is already absent, do we remove that row.

The same maintenance job removes unattached uploads older than twenty-four hours and marks attached uploads still pending after an hour as failed. Those age checks use the upload’s creation time. Failed assets with a provider ID also get cleanup attempts. Otherwise an abandoned composer would quietly become permanent paid storage.

Stream took encoding, renditions, and video delivery off our list. We still own the upload’s identity, its relationship to a post, what the feed shows while it processes, and whether deletion eventually finishes. That’s a manageable amount of video work for a forum. We get hosted clips alongside our R2 images, keep the application on CloudFlare, and leave building an encoding service for a day when we actually need one.