1. The approach
Use Astro to build the pages, Cloudflare Workers to serve them, and Cloudflare R2 to store the films. Each client gets a stable URL. A Markdown file describes the collection; the video files live separately in storage.
This suits a small film-delivery library. It does not include a social feed, recommendations or a public upload service. This guide is a build recipe, not a downloadable copy of this website.
| Service | Use it for |
|---|---|
| R2 | Preserved originals and MP4 playback files. This site uses R2 for both. |
| Stream, optionally | Managed encoding and adaptive playback when viewers need different quality levels. Keep originals in R2 for downloads. |
| Astro assets | Responsive posters, SVGs, fonts and page files. Cloudflare Images is optional; it is not needed for this setup. |
Budget for stored GB, R2 operations, Workers usage and your domain. R2 has no internet-egress charge, but storage and operations can still cost money. Stream uses stored and delivered minutes. Check the current R2, Workers and Stream pricing before choosing a plan.
2. Start the project
You need a Cloudflare account with R2 enabled, a domain you control, Node.js supported by the current Astro release, and FFmpeg for preparing files. Use your own design or a theme you are licensed to use.
Run this in a terminal, choosing the minimal template and installing dependencies when prompted. See Astro’s setup instructions for current prerequisites.
npm create astro@latest film-delivery -- --template minimal
cd film-delivery
npm install
npm install -D wrangler @astrojs/check typescript
npx wrangler login
npx wrangler r2 bucket create client-filmsKeep Astro’s output static. Add the following Wrangler configuration at the project root, then implement the Worker and pages described below. Creating the project and bucket alone does not create a working video website.
// wrangler.jsonc — a starting configuration, not the whole app
{
"name": "film-delivery",
"main": "worker/index.ts",
"compatibility_date": "2026-10-30",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"run_worker_first": true
},
"r2_buckets": [
{ "binding": "FILMS", "bucket_name": "client-films" }
]
}The ASSETS binding serves the built site; FILMS reads the R2 bucket. Running the Worker first lets it apply media access and page headers consistently. Cloudflare documents these bindings here.
Suggested project structure
src/
content.config.ts # Typed collection schema
content/collections/*.md # One file per collection
images/ # Posters, never full video files
pages/index.astro # Explicitly featured videos only
pages/[slug].astro # Collection pages
pages/library-admin.astro # Example only: choose your own address
pages/embed/[slug]/[id].astro
worker/
index.ts # Static pages + media requests
published-media.ts # Generated list of published keys
scripts/
media-manifest.mjs # Generate that list during build
media/ # Local working files; Git-ignored
wrangler.jsoncUsing an AI coding assistant? Start with Build the website, followed by Implement playback, downloads and embeds. These prompts describe the implementation work missing from a fresh scaffold.
3. Prepare and upload a film
Keep the delivered original unchanged. Make a separate H.264/AAC MP4 for broad playback compatibility, and select a poster from a representative moment. Keep an independent backup of the original.
mkdir -p media src/images
# Keep source.mov unchanged. -n prevents overwriting output files.
ffmpeg -n -i source.mov -map 0:v:0 -map '0:a:0?' \
-c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p \
-c:a aac -b:a 192k -movflags +faststart media/film.mp4
# Choose a representative moment; this example uses 10 seconds.
ffmpeg -n -ss 10 -i media/film.mp4 -frames:v 1 \
-vf scale=1600:-2 -q:v 3 src/images/example.jpg
# Record the actual duration, dimensions and byte count.
ffprobe -v error -show_entries \
format=duration,size:stream=width,height,codec_name \
-of json media/film.mp4This example preserves the source dimensions. Choose a smaller playback resolution if the file is too heavy for your audience’s connections. Check the picture, sound, duration and orientation before uploading; a filename is not proof of its contents.
For large videos, use an S3-compatible upload tool with multipart support. The AWS CLI automatically uses multipart uploads for large files; a resumable uploader with saved transfer state is preferable for unreliable connections. Follow Cloudflare’s upload instructions and check the current limits.
# Requires the AWS CLI and an R2 S3 token scoped to your bucket.
# Configure credentials locally; do not paste them into a prompt.
aws configure --profile film-r2
# Replace YOUR_ACCOUNT_ID with your own Cloudflare account ID.
aws s3 cp media/film.mp4 s3://client-films/example/film.mp4 \
--endpoint-url https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com \
--profile film-r2
aws s3 cp source.mov s3://client-films/example/original.mov \
--endpoint-url https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com \
--profile film-r2Leave public bucket access disabled. Verify each uploaded key and byte count, and checksums where your transfer tool supports them. Do not assume a multipart ETag is the whole file’s MD5 checksum. Use new keys for replacements instead of overwriting a file someone may be downloading.
4. Create a client collection
Define a typed Astro content collection and generate a route for each Markdown filename. For example, src/content/collections/alex-and-sam.md becomes /alex-and-sam/. This sample matches the shape used by Box Office; your schema and page code must implement it. Astro’s collection guide covers loading, validation and route generation.
Illustrative values only: replace the names, date, duration, dimensions, filenames and byte counts with your actual information.
---
names: Alex & Sam
published: '2026-11-01'
videos:
- id: ceremony
title: The ceremony
kind: feature
featured: false
# featuredOrder: 1
duration: 300
quality: Full HD
aspectRatio: 16/9
poster: ../../images/example.jpg
posterAlt: Describe the actual frame.
playbackKey: example/film.mp4
allowDownloads: true
downloads:
- label: Download MP4
role: playback
key: example/film.mp4
filename: Alex-and-Sam.mp4
bytes: 123456789
format: MP4
- label: Download original
role: original
key: example/original.mov
filename: Alex-and-Sam-Original.mov
bytes: 987654321
format: MOV
---- Add more entries under
videosfor ceremonies, edits and clips. Every video needs a unique ID within its collection. kind: featuremeans a large player on the collection page.kind: clipmeans a smaller thumbnail. Neither makes it public on the homepage.- Set
featured: trueonly when you want that video linked from the public homepage. UsefeaturedOrderto choose the order. - Keep the original and viewing copy as separate downloads. If the true original is unavailable, label an encode as “best available” instead.
Make the build generate an explicit list of published media keys. The Worker should serve only files on that list. Uploading a recovery backup or an unfinished film must not make it accessible through the website.
5. Make watching and downloading work
Render the poster first. Create the native video player when someone presses Play, with controls and playsinline. Keep the original download separate from the playback file.
- Stream R2 responses; never read an entire large film into Worker memory.
- Support
HEAD, byte-range requests and206/416responses so seeking and resumed downloads work. Test start, middle and suffix ranges. - Set the correct media type and length. Download links need
Content-Disposition: attachmentwith a safe filename. - For iPhone users, keep brief help available: download the MP4, open Files → Downloads, then Share → Save Video. Test the real device; not every file or app exposes that action.
For blogs, provide a standalone embed page containing only the player, poster and collection link. Use preload="none", allow framing on that page, and keep collection pages and directories protected from framing. Replace the example domain and path below with your implemented route.
<div style="position:relative;aspect-ratio:16/9">
<iframe
src="https://films.example.com/embed/alex-and-sam/ceremony/"
title="Alex and Sam — Ceremony"
loading="lazy"
allow="fullscreen; picture-in-picture"
allowfullscreen
style="position:absolute;inset:0;width:100%;height:100%;border:0"
></iframe>
</div>Unlisted is not private. Noindex tells search engines not to index a page; it does not stop someone opening or forwarding the URL. An unlisted client directory can expose every collection to someone who finds it. Use authentication on both the pages and media routes if the films require controlled access. Hiding a Download button does not prevent a viewer from saving playable media.
6. Move from Vimeo or another library
- Inventory first. Record source IDs, titles, privacy, durations, file versions, captions and posters. Include only media you own or have permission to move.
- Copy and verify. Preserve originals when available. Use resumable transfers and keep a recovery record. Keep the old service running during the move.
- Group deliberately. Decide which videos belong together. Confirm uncertain names and dates instead of guessing.
- Map the URLs. Store old video ID → collection URL → embed URL. Numeric-ID aliases make existing embeds easier to replace.
- Replace exact matches. Update your website’s iframe sources and ordinary links. Check shortcodes and Vimeo-specific scripts separately. Leave unrelated creators’ videos alone.
- Test, then notify. Check the live films, downloads and replacement embeds before changing your old subscription or sending client links.
Old provider URLs do not automatically redirect to your new site. Keep the migration map and source metadata in a private archive, outside the published media list. The migration prompt turns this sequence into a brief for your coding assistant.
7. Publish and check
Add meaningful tests and a build step that generates the media allowlist. Then run the release commands below. Connect your domain through the Worker’s domain settings and verify it serves the new deployment.
# After implementing the pages, Worker, manifest and tests:
npx astro check
npm test
npm run build # Must also generate published-media.ts
npx wrangler deploy --dry-run
npx wrangler deploy- Open a real collection on desktop and mobile, in light and dark mode.
- Play, pause and seek; check audio and captions. Confirm no video bytes load before Play.
- Download each offered version. Check filename, size and that the file actually plays.
- Try the embed on a separate website. Test copy buttons and the first-poster sharing image.
- Confirm unknown media paths and unpublished archive files return 404. Check that unlisted pages remain noindex.
- Test the iPhone Files/Photos handoff on a physical device. Desktop emulation cannot verify its share sheet.
Measure performance, but do not promise a permanent 100/100 score. A client page’s intentional noindex setting can lower its SEO score. Keep page and media backups, watch storage/request usage, and repeat the playback checks after substantial changes.
8. Copy the prompts
Use these in order in a coding assistant that can work in your project. Replace the bracketed placeholders with your own details. Review the generated code and use the launch checks above; a prompt is a brief, not proof that the result works.
1. Build the website
Build a low-volume client film-delivery website in this Astro project. Use the current official Astro and Cloudflare documentation and inspect the installed versions first. My brand is [NAME], my domain is [DOMAIN], and my own design reference is [REFERENCE].
Use static Astro pages and a Cloudflare Worker with ASSETS and FILMS (R2) bindings. One Markdown file in src/content/collections creates /slug. Define a typed content collection with names, optional date/venue/location, published, and videos containing id, title, kind (feature or clip), featured (false by default), optional featuredOrder, duration, quality, aspectRatio, poster, posterAlt, playbackKey, allowDownloads and download entries (key, filename, bytes, format, label, role).
Build a minimal homepage that lists only explicitly featured videos, collection pages with large feature films and smaller clips, and an unlisted admin directory at an address I choose, with search and copy-link actions. Paginate large clip collections. A featured clip must link to the page that contains it. Use text, CSS, SVG and responsive local posters; no client framework or third-party video player for R2 playback. Follow prefers-color-scheme for light/dark mode. Show concise labels, with download help collapsed. Do not request video bytes until Play. Use the first video's poster for each collection's Open Graph image.
Implement the pages, schema, components, scripts and meaningful tests; do not stop at a mockup. Keep credentials and video files out of Git and browser code. Do not make any real collection featured or public without my selection. Tell me exactly what remains before deployment.2. Implement playback, downloads and embeds
Implement the R2 delivery layer for this Astro site. Serve /media/<key> through a Worker using a private FILMS bucket. Generate an explicit published-key allowlist from collection content at build time; reject every key not on the list. Do not expose a bucket listing or serve archive/backup objects. Normalize and validate keys; reject traversal and malformed encoding.
Stream object bodies without buffering whole videos. Support GET, HEAD, valid single byte ranges (including suffix ranges), HTTP 206/416, Content-Length, Content-Range, Accept-Ranges, ETag and conditional requests. Set the correct media type. For downloads, return Content-Disposition: attachment with a sanitized filename. Keep preserved originals separate from playback copies and respect allowDownloads in the publication allowlist.
Add a standalone /embed/<slug>/<video-id>/ page using native video controls, playsinline, preload="none", a poster, captions when available, and a link to its collection. Allow framing only on successful embed pages; deny it on collections and the directory. Add copyable responsive iframe HTML. No autoplay or video requests on initial load. Preserve Vimeo numeric-ID aliases if we import Vimeo videos.
Keep collection, directory and embed pages noindex, with only intentionally public pages in the sitemap. Explain that noindex and hidden URLs are not access control. Test seeking, resume, filenames, unknown keys, unpublished originals, errors, mobile sizes and actual playback. Do not claim physical iPhone download testing unless it was performed.3. Move an existing video library
Plan and execute a migration of videos I own or have permission to move from [SOURCE] to my film-delivery site. Start with a read-only inventory: source ID, title, privacy, duration, file versions, original availability, captions and poster. Keep credentials in a local secure credential mechanism, never in the inventory, repository, browser or chat output.
Show me the proposed collection grouping and ask how private videos should be handled before changing access. Preserve original files when available and label best-available encodes honestly when originals are missing. Copy first; do not delete source files or downgrade the old service. Use resumable multipart transfers and persistent transfer records, with size/checksum verification where available. Keep an independent recovery backup of metadata and transfer records outside the public allowlist.
Create stable collection URLs and a source-ID-to-new-URL map. Deduplicate cautiously using source IDs and file evidence; do not guess couple identities, wedding dates or ownership from filenames. Audit my specified website sources for old links. Replace only exact matches: player URLs become new embeds, ordinary links become collection links, and provider-specific scripts/shortcodes need individual review. Leave other creators' videos alone. Verify playback, seeking, downloads and published replacements before reporting that migration is complete. Draft any requested client notifications separately; do not send them without explicit authorization.4. Verify and publish
Review this film-delivery site before publishing. Check the schema, collection routes, real media metadata and explicit featured selection. Ensure credentials, backups, private notes and unpublished objects cannot be retrieved. Verify that unlisted pages have noindex in HTML and HTTP headers, while the homepage and intended public guide pages remain indexable. If I need authenticated privacy, implement it on both page and media routes before release.
Run the project's checks and build. Test desktop/mobile layouts in system light and dark modes, keyboard controls, clip dialogs, copying links, first-poster Open Graph metadata, lazy playback, captions, seeking/range requests, original downloads and the separate embed player. Check an independent website can embed the player. Test iPhone Files/Photos on a physical device if available, otherwise state that limitation. Do not promise a perfect PageSpeed score or remove noindex to inflate SEO scores.
Publish only to the domain and Cloudflare account I have authorized. Confirm the active deployment and live URLs, not just a successful build or Git push. Record what passed, what was actually deployed and any remaining limitations. Preserve source originals and the previous provider until the migration and old-link replacements are verified.