| name | media-cloud-upload |
| description | Upload processed media to cloud platforms: Cloudflare Stream, Mux Video, Bunny Stream, YouTube Data API, AWS S3 + CloudFront, Vimeo API, rclone to any cloud. Use when the user asks to upload to Cloudflare Stream, push to Mux, upload a video to YouTube, deploy to Bunny Stream, sync to S3, publish a finished encode to CDN, or distribute media to a video platform.
|
| argument-hint | [service] [file] |
Media Cloud Upload
Context: $ARGUMENTS
Quick start
- Cloudflare Stream (fast, HLS+DASH auto): → Step 3a
- Mux Video (signed URL, pro API): → Step 3b
- Bunny Stream (cheap CDN): → Step 3c
- AWS S3 + CloudFront (DIY): → Step 3d
- YouTube (OAuth required): → Step 3e
- Vimeo (tus via PyVimeo): → Step 3f
- rclone to any cloud (Drive/OneDrive/B2/...): → Step 3g
When to use
- Finished ffmpeg encode needs adaptive-bitrate delivery (Cloudflare Stream / Mux).
- Raw MP4 needs to sit on a CDN with a URL (S3 + CloudFront, Bunny).
- Publishing to a consumer video platform (YouTube, Vimeo).
- Backup, archive, or sync media to arbitrary cloud storage (rclone).
Step 1 — Pick a service and gather credentials
| Service | Env vars | Notes |
|---|
| Cloudflare Stream | CF_TOKEN, CF_ACCOUNT_ID | API token with Stream:Edit |
| Mux | MUX_TOKEN, MUX_SECRET | Access-token pair (Basic Auth) |
| Bunny Stream | BUNNY_KEY, BUNNY_LIB_ID | Library-level AccessKey |
| AWS S3 | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | Or ~/.aws/credentials |
| YouTube Data API | OAuth 2.0 client JSON + stored refresh token | Upload costs 1600 quota units |
| Vimeo | VIMEO_TOKEN | Personal access token, upload scope |
| rclone | rclone config remote | Token stored in ~/.config/rclone |
Never commit tokens. Read from env only. Rotate leaked tokens immediately.
Step 2 — Install SDK / CLI
brew install curl rclone awscli
pip install google-api-python-client google-auth-oauthlib PyVimeo
pip install tuspy
Check everything with:
uv run ${CLAUDE_SKILL_DIR}/scripts/cloud.py check
Step 3 — Upload
3a. Cloudflare Stream
Direct upload (≤ 200 MB):
curl -X POST -H "Authorization: Bearer $CF_TOKEN" \
-F "file=@video.mp4" \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/stream"
TUS resumable (any size, required > 200 MB):
curl -X POST -H "Authorization: Bearer $CF_TOKEN" \
-H "Tus-Resumable: 1.0.0" \
-H "Upload-Length: $(stat -f%z video.mp4)" \
-H "Upload-Metadata: name $(echo -n video.mp4 | base64)" \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/stream?direct_user=true" -D -
Response includes uid; playback URLs:
https://customer-<code>.cloudflarestream.com/<uid>/manifest/video.m3u8 # HLS
https://customer-<code>.cloudflarestream.com/<uid>/manifest/video.mpd # DASH
https://customer-<code>.cloudflarestream.com/<uid>/downloads/default.mp4 # MP4
https://customer-<code>.cloudflarestream.com/<uid>/thumbnails/thumbnail.jpg
3b. Mux Video
curl -X POST -u $MUX_TOKEN:$MUX_SECRET \
-H "Content-Type: application/json" \
-d '{"new_asset_settings":{"playback_policy":["public"]},"cors_origin":"*"}' \
https://api.mux.com/video/v1/uploads
curl -X PUT --data-binary @video.mp4 "$SIGNED_URL"
Playback: https://stream.mux.com/<playback_id>.m3u8, thumbnail https://image.mux.com/<playback_id>/thumbnail.jpg.
3c. Bunny Stream
curl -X POST -H "AccessKey: $BUNNY_KEY" -H "Content-Type: application/json" \
-d '{"title":"My Clip"}' \
"https://video.bunnycdn.com/library/$BUNNY_LIB_ID/videos"
curl -X PUT -H "AccessKey: $BUNNY_KEY" --upload-file video.mp4 \
"https://video.bunnycdn.com/library/$BUNNY_LIB_ID/videos/$VIDEO_ID"
Playback: https://iframe.mediadelivery.net/embed/$BUNNY_LIB_ID/$VIDEO_ID or HLS at https://vz-<hash>.b-cdn.net/$VIDEO_ID/playlist.m3u8.
3d. AWS S3 + CloudFront
aws s3 cp video.mp4 s3://my-bucket/videos/clip.mp4 \
--content-type video/mp4 --acl public-read --cache-control "max-age=31536000"
Pre-signed URL for private buckets:
aws s3 presign s3://my-bucket/videos/clip.mp4 --expires-in 3600
CloudFront: create a distribution with the bucket as origin, enable CORS, use the CloudFront URL (https://d12345.cloudfront.net/videos/clip.mp4).
3e. YouTube Data API v3
Requires OAuth 2.0 (installed-app flow). Run once with the user present to get a refresh token; subsequent runs are silent:
uv run ${CLAUDE_SKILL_DIR}/scripts/cloud.py youtube \
--file video.mp4 --title "My Title" --description "desc" \
--tags demo,ffmpeg --privacy unlisted
Uses googleapiclient.http.MediaFileUpload with resumable chunked upload.
3f. Vimeo
import vimeo, os
v = vimeo.VimeoClient(token=os.environ["VIMEO_TOKEN"])
uri = v.upload("video.mp4", data={"name": "Clip", "description": "..."})
print(v.get(uri + "?fields=link,player_embed_url").json())
PyVimeo uses TUS under the hood and resumes automatically.
3g. rclone (any cloud)
rclone config
rclone copy --progress video.mp4 gdrive:videos/
rclone link gdrive:videos/video.mp4
rclone mount gdrive: /mnt/gdrive
Supports Google Drive, OneDrive, Dropbox, Backblaze B2, S3, R2, Azure Blob, FTP, SFTP, WebDAV, and many more behind one CLI.
Step 4 — Verify and get playback URL
curl -I <playback_url> — expect 200 and a video content-type (or application/vnd.apple.mpegurl for HLS).
- Drop the URL into
ffplay or mpv to smoke-test playback.
- For HLS manifests, check that both master + variant playlists load:
curl -s "$PLAYBACK_URL" | head -40
- For Mux / Cloudflare Stream, poll the API until
status: ready before sharing the link — the upload returns before the transcode finishes.
- Register a webhook (Mux → Settings → Webhooks; Cloudflare → Stream webhooks) to avoid polling in production.
Available scripts
scripts/cloud.py — unified CLI: check, cloudflare-stream, mux, bunny, s3, rclone, youtube, vimeo. Stdlib + system curl/rclone/aws. --dry-run prints the command, --verbose dumps full JSON.
Reference docs
- Read
references/cloud.md for: service comparison, upload-protocol cheatsheet (direct POST vs TUS vs signed URL vs multipart), auth method per service, webhook/callback patterns, the exact shape of playback URLs, and end-to-end recipes (post-encode → Cloudflare, podcast → S3 → CloudFront, CI → Mux on merge).
Gotchas
- Tokens in env only. Never hardcode, never commit
.env, never echo in dry-run unless explicitly asked.
- Cloudflare Stream 200 MB cap on direct POST. Larger files MUST use TUS (
?direct_user=true).
- Cloudflare Stream output is always HLS + DASH + MP4 + thumb. You do not generate manifests yourself; they are produced after transcode finishes (≈ real-time).
- Mux uploads return before encoding. Poll
GET /video/v1/assets/<id> until status == "ready" or use webhooks.
- Mux
playback_policy. public = open HLS URL; signed = you must mint a JWT per play (HS256 with signing key from Mux). Pick public unless you really need DRM-lite.
- Bunny Stream is two-step. Create video shell → PUT bytes. Skipping step 1 just 404s.
- YouTube Data API quota. 10,000 units/day default; an upload costs 1,600. Three uploads/day before you hit quota. Apply for a quota increase for production.
- YouTube OAuth flow. Installed-app flow stores a refresh token; keep it in
~/.config/yt-upload-token.json (gitignored). yt-dlp --cookies-from-browser chrome works for personal re-auth but violates YouTube ToS for commercial use.
- Vimeo tier caps weekly upload quota. Basic = 500 MB/week; Plus/Pro/Business raise it. Check
/me → upload_quota before large jobs.
- S3 CORS for web playback. MP4 in a
<video> tag needs a CORSRule allowing GET from your origin. HLS needs Range in AllowedHeaders.
- CloudFront propagation. New distributions take 5–15 min to deploy globally. Invalidations cost money past the free tier — cache-bust with filename hashes instead.
- OAI vs OAC for private S3 origins. Origin Access Identity is legacy; use Origin Access Control (OAC) for new distributions.
- Cloudflare Stream + Mux auto-transcode to ABR. You do not need to pre-package HLS; ship a single H.264/HEVC MP4 and they ladder it for you.
- rclone parallelism.
--transfers 8 --checkers 16 --fast-list for big batches; --progress for live ETA.
Examples
Example 1: Push an ffmpeg encode to Cloudflare Stream
ffmpeg -i master.mov -c:v libx264 -crf 20 -preset slow -c:a aac -b:a 192k encoded.mp4
uv run ${CLAUDE_SKILL_DIR}/scripts/cloud.py cloudflare-stream --file encoded.mp4
Example 2: Mux for a CI encode pipeline
uv run ${CLAUDE_SKILL_DIR}/scripts/cloud.py mux --file out.mp4 --verbose
Example 3: Mirror a render to Backblaze B2 via rclone
rclone copy --progress --transfers 8 render/ b2:my-bucket/renders/2026-04-17/
Example 4: Upload a podcast MP3 to S3 with a public URL
uv run ${CLAUDE_SKILL_DIR}/scripts/cloud.py s3 \
--file ep42.mp3 --bucket my-podcast --key 2026/ep42.mp3 --acl public-read
Troubleshooting
Error: 401 Unauthorized on Cloudflare Stream
Cause: CF_TOKEN lacks Stream:Edit scope or targets the wrong account.
Solution: Create a scoped token at https://dash.cloudflare.com/profile/api-tokens; verify $CF_ACCOUNT_ID matches the dashboard URL.
Error: Mux upload returns URL but ffprobe on playback URL fails
Cause: The asset is still transcoding. Mux creates the playback ID immediately but the manifest is not written until encoding finishes.
Solution: Poll GET /video/v1/assets/{asset_id} until status == "ready", or subscribe to the video.asset.ready webhook.
Error: YouTube upload fails with quotaExceeded
Cause: Daily 10,000-unit quota consumed (≈ 6 uploads).
Solution: Wait until Pacific midnight reset, or request a quota increase at https://support.google.com/youtube/contact/yt_api_form.
Error: SignatureDoesNotMatch on S3 upload
Cause: System clock skew, wrong region in endpoint, or credentials for a different AWS account.
Solution: aws configure list, sync clock (sudo sntp -sS time.apple.com), ensure --region matches the bucket.
Error: Bunny Stream PUT returns 404
Cause: VIDEO_ID not created first, or BUNNY_LIB_ID wrong.
Solution: POST to .../library/$BUNNY_LIB_ID/videos first, capture the guid, then PUT to that exact ID.
Error: rclone: failed to create file system for Google Drive
Cause: OAuth token expired or client_id/client_secret misconfigured.
Solution: rclone config reconnect <remote>: and complete the browser flow.